diff --git a/cmd/skillshare/backup.go b/cmd/skillshare/backup.go index 43a80eaa..03aa278a 100644 --- a/cmd/skillshare/backup.go +++ b/cmd/skillshare/backup.go @@ -206,7 +206,7 @@ func previewBackup(targetName, targetPath string) error { return nil } - timestamp := time.Now().Format("2006-01-02_15-04-05") + timestamp := backup.NewTimestamp() backupPath := filepath.Join(backupDir, timestamp, targetName) ui.Info("%s: would backup to %s", targetName, backupPath) @@ -386,6 +386,18 @@ func cmdRestore(args []string) error { } } + // Preserve the legacy "restore agents" alias for the canonical shared target + // in global mode when no explicit agent target was requested. + if kind == kindAgents && targetName == "" && mode != modeProject { + cfg, cfgErr := config.Load() + if cfgErr == nil { + if _, _, resolveErr := resolveConfiguredRestoreTarget(cfg.Targets, "agents"); resolveErr == nil { + kind = kindSkills + targetName = "agents" + } + } + } + // Agent restore uses agent-specific backup entries (name suffixed with "-agents") if kind == kindAgents { return restoreAgentBackup(mode, cwd, targetName, fromTimestamp, force, dryRun) @@ -406,8 +418,12 @@ func cmdRestore(args []string) error { return err } - target, exists := cfg.Targets[targetName] - if !exists { + resolvedTargetName, target, err := resolveConfiguredRestoreTarget(cfg.Targets, targetName) + if err != nil { + return err + } + sc := target.SkillsConfig() + if sc.Path == "" { return fmt.Errorf("target '%s' not found in config", targetName) } @@ -418,20 +434,18 @@ func cmdRestore(args []string) error { } opts := backup.RestoreOptions{Force: force} - - sc := target.SkillsConfig() if dryRun { if fromTimestamp != "" { - return previewRestoreFromTimestamp(targetName, sc.Path, fromTimestamp, opts) + return previewRestoreFromTimestamp(resolvedTargetName, sc.Path, fromTimestamp, opts) } - return previewRestoreFromLatest(targetName, sc.Path, opts) + return previewRestoreFromLatest(resolvedTargetName, sc.Path, opts) } var restoreErr error if fromTimestamp != "" { - restoreErr = restoreFromTimestamp(targetName, sc.Path, fromTimestamp, opts) + restoreErr = restoreFromTimestamp(resolvedTargetName, sc.Path, fromTimestamp, opts) } else { - restoreErr = restoreFromLatest(targetName, sc.Path, opts) + restoreErr = restoreFromLatest(resolvedTargetName, sc.Path, opts) } e := oplog.NewEntry("restore", statusFromErr(restoreErr), time.Since(start)) @@ -447,6 +461,22 @@ func cmdRestore(args []string) error { return restoreErr } +func resolveConfiguredRestoreTarget(targets map[string]config.TargetConfig, requested string) (string, config.TargetConfig, error) { + candidates := make([]string, 0, len(targets)) + for name := range targets { + candidates = append(candidates, name) + } + + resolvedName, ok, err := config.ResolveTargetNameCandidate(requested, candidates) + if err != nil { + return "", config.TargetConfig{}, err + } + if !ok { + return "", config.TargetConfig{}, nil + } + return resolvedName, targets[resolvedName], nil +} + // restoreTUIDispatch handles the no-args TUI flow for restore. func restoreTUIDispatch(noTUI bool) error { cfg, err := config.Load() diff --git a/cmd/skillshare/backup_restore_test.go b/cmd/skillshare/backup_restore_test.go new file mode 100644 index 00000000..121b7580 --- /dev/null +++ b/cmd/skillshare/backup_restore_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "path/filepath" + "testing" + + "skillshare/internal/backup" + "skillshare/internal/config" +) + +func TestCmdRestore_AliasRestoresCanonicalLatestBackup(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + sourceDir := filepath.Join(t.TempDir(), "source") + mustAddSkill(t, sourceDir, "alpha") + + cfg := &config.Config{ + Source: sourceDir, + Targets: map[string]config.TargetConfig{ + "universal": {Path: filepath.Join(home, ".agents", "skills")}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + mustWriteFile(t, filepath.Join(backup.BackupDir(), "2025-03-20_18-45-00", "universal", "alpha", "SKILL.md"), "# Alpha\n") + + if err := cmdRestore([]string{"agents", "--force"}); err != nil { + t.Fatalf("cmdRestore(alias latest) error = %v", err) + } + + assertFileContent(t, filepath.Join(home, ".agents", "skills", "alpha", "SKILL.md"), "# Alpha\n") +} diff --git a/cmd/skillshare/collect.go b/cmd/skillshare/collect.go index 09b221a8..eedc3401 100644 --- a/cmd/skillshare/collect.go +++ b/cmd/skillshare/collect.go @@ -67,6 +67,44 @@ func cmdCollect(args []string) error { applyModeLabel(mode) kind, rest := parseKindArg(rest) + if kind == kindAgents { + if hasArg(rest, "--resources") { + return fmt.Errorf("--resources cannot be used with agents") + } + + opts := parseCollectOptions(rest) + scope := "global" + cfgPath := config.ConfigPath() + if mode == modeProject { + scope = "project" + cfgPath = config.ProjectConfigPath(cwd) + } + + summary := newCollectLogSummary(kind, scope, opts) + switch mode { + case modeProject: + summary, err = cmdCollectProjectAgents(cwd, opts, start) + default: + cfg, loadErr := config.Load() + if loadErr != nil { + err = collectCommandError(loadErr, opts.jsonOutput) + logCollectOp(cfgPath, start, err, summary) + return err + } + summary, err = cmdCollectAgents(cfg, opts, start) + } + + logCollectOp(cfgPath, start, err, summary) + return err + } + + resources, rest, err := parseResourceFlags(rest, resourceFlagOptions{ + defaultSelection: resourceSelection{skills: true}, + }) + if err != nil { + return err + } + opts := parseCollectOptions(rest) scope := "global" cfgPath := config.ConfigPath() @@ -75,36 +113,38 @@ func cmdCollect(args []string) error { cfgPath = config.ProjectConfigPath(cwd) } - summary := newCollectLogSummary(kind, scope, opts) - + summary := newCollectLogSummary(kindSkills, scope, opts) switch mode { case modeProject: - if kind == kindAgents { - summary, err = cmdCollectProjectAgents(cwd, opts, start) - } else { - summary, err = cmdCollectProject(opts, cwd, start) - } + summary, err = cmdCollectProject(opts, cwd, start, resources) default: + if resources.onlyManaged() { + summary, err = runManagedOnlyCollect(summary, opts, start, "", resources) + break + } cfg, loadErr := config.Load() if loadErr != nil { err = collectCommandError(loadErr, opts.jsonOutput) logCollectOp(cfgPath, start, err, summary) return err } - if kind == kindAgents { - summary, err = cmdCollectAgents(cfg, opts, start) - } else { - summary, err = cmdCollectGlobal(cfg, opts, start) - } + summary, err = cmdCollectGlobal(cfg, opts, start, resources) } logCollectOp(cfgPath, start, err, summary) return err } -func cmdCollectGlobal(cfg *config.Config, opts collectOptions, start time.Time) (collectLogSummary, error) { +func cmdCollectGlobal(cfg *config.Config, opts collectOptions, start time.Time, resources resourceSelection) (collectLogSummary, error) { summary := newCollectLogSummary(kindSkills, "global", opts) + if resources.onlyManaged() { + if opts.targetName != "" || opts.collectAll { + return summary, collectCommandError(fmt.Errorf("target selection is only supported when collecting skills"), opts.jsonOutput) + } + return runManagedOnlyCollect(summary, opts, start, "", resources) + } + targets, err := selectCollectTargets(cfg, opts.targetName, opts.collectAll, opts.jsonOutput) if err != nil { return summary, collectCommandError(err, opts.jsonOutput) @@ -113,13 +153,17 @@ func cmdCollectGlobal(cfg *config.Config, opts collectOptions, start time.Time) return summary, nil } - return runCollectPlan(collectPlan{ - kind: kindSkills, source: cfg.Source, - scan: func(warn bool) collectResources { - skills := collectLocalSkills(targets, cfg.Source, cfg.Mode, warn) - return toCollectResources(skills, cfg.Source, skillDisplayItem, sync.PullSkills) - }, - }, opts, start, "global") + if !resources.includesManaged() { + return runCollectPlan(collectPlan{ + kind: kindSkills, source: cfg.Source, + scan: func(warn bool) collectResources { + skills := collectLocalSkills(targets, cfg.Source, cfg.Mode, warn) + return toCollectResources(skills, cfg.Source, skillDisplayItem, sync.PullSkills) + }, + }, opts, start, "global") + } + + return runCombinedCollect(summary, opts, start, cfg.Source, cfg.Mode, targets, "", resources) } func selectCollectTargets(cfg *config.Config, targetName string, collectAll, jsonOutput bool) (map[string]config.TargetConfig, error) { @@ -150,15 +194,200 @@ func selectCollectTargets(cfg *config.Config, targetName string, collectAll, jso return nil, nil } +func runManagedOnlyCollect(summary collectLogSummary, opts collectOptions, start time.Time, projectRoot string, resources resourceSelection) (collectLogSummary, error) { + if opts.jsonOutput { + result, err := collectManagedResources(projectRoot, resources, opts.dryRun, opts.force) + summary = accumulateCollectLogSummary(summary, result) + return summary, collectOutputJSON(result, opts.dryRun, start, err) + } + + result, err := collectManagedResources(projectRoot, resources, opts.dryRun, opts.force) + summary = accumulateCollectLogSummary(summary, result) + return summary, renderManagedCollectResult(projectRoot, resources, opts.dryRun, result, err) +} + +func runCombinedCollect( + summary collectLogSummary, + opts collectOptions, + start time.Time, + source string, + globalMode string, + targets map[string]config.TargetConfig, + projectRoot string, + resources resourceSelection, +) (collectLogSummary, error) { + var sp *ui.Spinner + if !opts.jsonOutput { + ui.Header(ui.WithModeLabel("Collect")) + sp = ui.StartSpinner("Scanning for local skills...") + } + + allLocalSkills := collectLocalSkills(targets, source, globalMode, !opts.jsonOutput) + if len(allLocalSkills) == 0 { + if sp != nil { + sp.Success("No local skills found") + } + } else if sp != nil { + sp.Success(fmt.Sprintf("Found %d local skill(s)", len(allLocalSkills))) + displayLocalCollectItems("Local skills found", skillCollectItems(allLocalSkills)) + } + + if opts.dryRun { + skillResult := plannedSkillCollectResult(allLocalSkills) + managedResult, managedErr := collectManagedResources(projectRoot, resources, true, opts.force) + result := mergePullResults(skillResult, managedResult) + summary = accumulateCollectLogSummary(summary, result) + + if opts.jsonOutput { + return summary, collectOutputJSON(result, true, start, managedErr) + } + + return summary, renderManagedCollectResult(projectRoot, resources, true, managedResult, managedErr) + } + + if !opts.force && len(allLocalSkills) > 0 { + if !confirmCollect("skills") { + ui.Info("Cancelled") + return summary, nil + } + } + + var skillResult *sync.PullResult + var skillErr error + if len(allLocalSkills) > 0 { + skillResult, skillErr = sync.PullSkills(allLocalSkills, source, sync.PullOptions{ + DryRun: false, + Force: opts.force, + }) + summary = accumulateCollectLogSummary(summary, skillResult) + if !opts.jsonOutput { + if skillErr == nil { + skillErr = renderCollectResult("skills", skillResult, source) + } + } else { + skillErr = combineCollectErrors(skillErr, collectResultError(skillResult)) + } + } + + managedResult, managedErr := collectManagedResources(projectRoot, resources, false, opts.force) + summary = accumulateCollectLogSummary(summary, managedResult) + + combinedErr := combineCollectErrors(skillErr, managedErr) + if opts.jsonOutput { + return summary, collectOutputJSON(mergePullResults(skillResult, managedResult), false, start, combinedErr) + } + + if renderErr := renderManagedCollectResult(projectRoot, resources, false, managedResult, managedErr); renderErr != nil { + combinedErr = combineCollectErrors(skillErr, renderErr) + } + return summary, combinedErr +} + +func skillCollectItems(skills []sync.LocalSkillInfo) []collectDisplayItem { + items := make([]collectDisplayItem, len(skills)) + for i, skill := range skills { + items[i] = skillDisplayItem(skill) + } + return items +} + +func plannedSkillCollectResult(skills []sync.LocalSkillInfo) *sync.PullResult { + if len(skills) == 0 { + return nil + } + names := make([]string, len(skills)) + for i, skill := range skills { + names[i] = skill.Name + } + return &sync.PullResult{ + Pulled: names, + Failed: make(map[string]error), + } +} + +func accumulateCollectLogSummary(summary collectLogSummary, result *sync.PullResult) collectLogSummary { + if result == nil { + return summary + } + summary.Pulled += len(result.Pulled) + summary.Skipped += len(result.Skipped) + summary.Failed += len(result.Failed) + return summary +} + +func mergePullResults(left, right *sync.PullResult) *sync.PullResult { + switch { + case left == nil: + return right + case right == nil: + return left + } + + merged := &sync.PullResult{ + Pulled: append(append([]string{}, left.Pulled...), right.Pulled...), + Skipped: append(append([]string{}, left.Skipped...), right.Skipped...), + Failed: make(map[string]error, len(left.Failed)+len(right.Failed)), + } + for name, err := range left.Failed { + merged.Failed[name] = err + } + for name, err := range right.Failed { + merged.Failed[name] = err + } + return merged +} + +func collectResultError(result *sync.PullResult) error { + if result == nil || len(result.Failed) == 0 { + return nil + } + return fmt.Errorf("some skills failed to collect") +} + +func combineCollectErrors(errs ...error) error { + parts := make([]string, 0, len(errs)) + for _, err := range errs { + if err == nil { + continue + } + parts = append(parts, err.Error()) + } + if len(parts) == 0 { + return nil + } + return fmt.Errorf("%s", joinErrors(parts)) +} + +func joinErrors(parts []string) string { + if len(parts) == 0 { + return "" + } + out := parts[0] + for i := 1; i < len(parts); i++ { + out += "; " + parts[i] + } + return out +} + +func hasArg(args []string, target string) bool { + for _, arg := range args { + if arg == target { + return true + } + } + return false +} + func printCollectHelp() { fmt.Println(`Usage: skillshare collect [agents] [target] [options] -Collect local skills or agents from target(s) to the source directory. +Collect local skills, agents, or managed resources from target(s) to source. Arguments: - [target] Target name to collect from (optional) + [target] Target name to collect from (optional; skills only) Options: + --resources LIST Collect only specific resources: skills,rules,hooks --all, -a Collect from all targets --dry-run, -n Preview changes without applying --force, -f Overwrite existing items in source and skip confirmation @@ -168,9 +397,10 @@ Options: --help, -h Show this help Examples: - skillshare collect claude Collect skills from the Claude target - skillshare collect --all Collect skills from all targets - skillshare collect --dry-run Preview what would be collected - skillshare collect agents claude Collect agents from the Claude target - skillshare collect agents --json Collect agents as JSON output`) + skillshare collect claude Collect skills from the Claude target + skillshare collect --all Collect skills from all targets + skillshare collect --resources rules,hooks Collect managed rules and hooks + skillshare collect --resources skills,hooks Collect skills and managed hooks + skillshare collect agents claude Collect agents from the Claude target + skillshare collect agents --json Collect agents as JSON output`) } diff --git a/cmd/skillshare/collect_project.go b/cmd/skillshare/collect_project.go index e0f4a08b..331a577a 100644 --- a/cmd/skillshare/collect_project.go +++ b/cmd/skillshare/collect_project.go @@ -9,9 +9,16 @@ import ( "skillshare/internal/ui" ) -func cmdCollectProject(opts collectOptions, root string, start time.Time) (collectLogSummary, error) { +func cmdCollectProject(opts collectOptions, root string, start time.Time, resources resourceSelection) (collectLogSummary, error) { summary := newCollectLogSummary(kindSkills, "project", opts) + if resources.onlyManaged() { + if opts.targetName != "" || opts.collectAll { + return summary, collectCommandError(fmt.Errorf("target selection is only supported when collecting skills"), opts.jsonOutput) + } + return runManagedOnlyCollect(summary, opts, start, root, resources) + } + runtime, err := loadProjectRuntime(root) if err != nil { return summary, collectCommandError(err, opts.jsonOutput) @@ -25,13 +32,17 @@ func cmdCollectProject(opts collectOptions, root string, start time.Time) (colle return summary, nil } - return runCollectPlan(collectPlan{ - kind: kindSkills, source: runtime.sourcePath, - scan: func(warn bool) collectResources { - skills := collectLocalSkills(targets, runtime.sourcePath, "", warn) - return toCollectResources(skills, runtime.sourcePath, skillDisplayItem, sync.PullSkills) - }, - }, opts, start, "project") + if !resources.includesManaged() { + return runCollectPlan(collectPlan{ + kind: kindSkills, source: runtime.sourcePath, + scan: func(warn bool) collectResources { + skills := collectLocalSkills(targets, runtime.sourcePath, "", warn) + return toCollectResources(skills, runtime.sourcePath, skillDisplayItem, sync.PullSkills) + }, + }, opts, start, "project") + } + + return runCombinedCollect(summary, opts, start, runtime.sourcePath, "", targets, root, resources) } func selectCollectProjectTargets(runtime *projectRuntime, targetName string, collectAll, jsonOutput bool) (map[string]config.TargetConfig, error) { diff --git a/cmd/skillshare/init_test.go b/cmd/skillshare/init_test.go index 59c8bb46..7abe8754 100644 --- a/cmd/skillshare/init_test.go +++ b/cmd/skillshare/init_test.go @@ -36,6 +36,8 @@ func TestCommitSourceFiles_CommitFailureIsReturned(t *testing.T) { runGit(t, repo, "init") runGit(t, repo, "config", "user.email", "test@example.com") runGit(t, repo, "config", "user.name", "Test User") + runGit(t, repo, "config", "core.hooksPath", ".git/hooks") + runGit(t, repo, "config", "core.hooksPath", ".git/hooks") hookPath := filepath.Join(repo, ".git", "hooks", "pre-commit") if err := os.WriteFile(hookPath, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { diff --git a/cmd/skillshare/main.go b/cmd/skillshare/main.go index 7e276f95..264f63cf 100644 --- a/cmd/skillshare/main.go +++ b/cmd/skillshare/main.go @@ -205,7 +205,7 @@ func printUsage() { cmd("uninstall", "...", "Remove skills/agents from source directory") cmd("list", "[agents] [pattern] [--all]", "List installed skills (or agents)") cmd("search", "[query]", "Search or browse GitHub for skills") - cmd("sync", "[agents] [--all]", "Sync skills/agents/extras to targets") + cmd("sync", "[agents] [--all|--resources ]", "Sync skills, rules/hooks, agents, and extras") cmd("status", "", "Show status of all targets") fmt.Println() @@ -230,7 +230,7 @@ func printUsage() { // Sync & Backup fmt.Println("SYNC & BACKUP") - cmd("collect", "[agents] [target]", "Collect local skills/agents from target(s)") + cmd("collect", "[agents] [target] [--resources ]", "Collect local skills, agents, or managed resources") cmd("backup", "", "Create backup of target(s)") cmd("restore", "", "Restore target from latest backup") cmd("trash", "[agents] list", "List trashed skills/agents") @@ -274,9 +274,11 @@ func printUsage() { fmt.Println(g + " skillshare status # Check current state") fmt.Println(" skillshare sync --dry-run # Preview before sync") fmt.Println(" skillshare sync agents # Sync agents only") - fmt.Println(" skillshare sync --all # Sync skills + agents + extras") + fmt.Println(" skillshare sync --all # Full sync: skills + rules/hooks + agents + extras") fmt.Println(" skillshare list --all # List skills + agents") + fmt.Println(" skillshare sync --resources rules,hooks # Sync managed rules and hooks") fmt.Println(" skillshare collect claude # Import local skills") + fmt.Println(" skillshare collect --resources rules,hooks # Import managed rules and hooks") fmt.Println(" skillshare install anthropics/skills/pdf -p # Project install") fmt.Println(" skillshare install repo -a my-agent # Install specific agent") fmt.Println(" skillshare target add cursor -p # Project target") diff --git a/cmd/skillshare/managed_resources.go b/cmd/skillshare/managed_resources.go new file mode 100644 index 00000000..612c0d1b --- /dev/null +++ b/cmd/skillshare/managed_resources.go @@ -0,0 +1,736 @@ +package main + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "skillshare/internal/backup" + "skillshare/internal/config" + "skillshare/internal/inspect" + "skillshare/internal/resources/adapters" + managedhooks "skillshare/internal/resources/hooks" + managed "skillshare/internal/resources/managed" + managedrules "skillshare/internal/resources/rules" + "skillshare/internal/sync" + "skillshare/internal/ui" +) + +type managedSyncResult struct { + resource string + updated []string + skipped []string + pruned []string +} + +func toManagedTargets(entries []syncTargetEntry) []managed.TargetSyncSpec { + specs := make([]managed.TargetSyncSpec, 0, len(entries)) + for _, entry := range entries { + specs = append(specs, managed.TargetSyncSpec{ + Name: entry.name, + Target: entry.target, + }) + } + return specs +} + +func syncManagedResourcesForEntries(entries, allEntries []syncTargetEntry, results []syncTargetResult, resources resourceSelection, projectRoot string, dryRun bool) ([]syncTargetResult, int) { + if len(results) == 0 { + results = make([]syncTargetResult, len(entries)) + } + + indexByName := make(map[string]int, len(entries)) + for i, entry := range entries { + indexByName[entry.name] = i + } + + rows := managed.Sync(managed.SyncRequest{ + ProjectRoot: projectRoot, + DryRun: dryRun, + Resources: managed.ResourceSet{ + Rules: resources.rules, + Hooks: resources.hooks, + }, + Targets: toManagedTargets(entries), + AllTargets: toManagedTargets(allEntries), + }) + + linesByTarget := make(map[string][]string, len(entries)) + errorsByTarget := make(map[string][]string, len(entries)) + for _, row := range rows { + idx, ok := indexByName[row.Target] + if !ok { + continue + } + + result := results[idx] + if result.name == "" { + entry := entries[idx] + result = syncTargetResult{ + name: entry.name, + mode: entry.mode, + include: entry.target.Include, + exclude: entry.target.Exclude, + } + } + + if row.Err != nil { + errorsByTarget[row.Target] = append(errorsByTarget[row.Target], row.Err.Error()) + results[idx] = result + continue + } + + result.stats.updated += len(row.Updated) + result.stats.pruned += len(row.Pruned) + if line := managedSyncLine(managedSyncResult{ + resource: row.Resource, + updated: row.Updated, + skipped: row.Skipped, + pruned: row.Pruned, + }); line != "" { + linesByTarget[row.Target] = append(linesByTarget[row.Target], line) + } + results[idx] = result + } + + failed := 0 + for i, entry := range entries { + result := results[i] + if result.name == "" { + result = syncTargetResult{ + name: entry.name, + mode: entry.mode, + include: entry.target.Include, + exclude: entry.target.Exclude, + } + } + hadPriorError := result.errMsg != "" + priorError := result.errMsg + + lines := linesByTarget[entry.name] + errorsByResource := errorsByTarget[entry.name] + + if len(errorsByResource) > 0 { + managedError := strings.Join(errorsByResource, "; ") + if hadPriorError { + result.errMsg = priorError + "; " + managedError + } else { + result.errMsg = managedError + failed++ + } + } + + if resources.onlyManaged() && result.errMsg == "" { + if len(lines) == 0 { + result.message = "no managed resource changes" + } else { + result.message = strings.Join(lines, "; ") + } + } else { + result.infos = append(result.infos, lines...) + } + results[i] = result + } + + return results, failed +} + +func managedSyncLine(result managedSyncResult) string { + if result.resource == "" { + return "" + } + parts := make([]string, 0, 3) + if len(result.updated) > 0 { + parts = append(parts, fmt.Sprintf("%d updated", len(result.updated))) + } + if len(result.skipped) > 0 { + parts = append(parts, fmt.Sprintf("%d unchanged", len(result.skipped))) + } + if len(result.pruned) > 0 { + parts = append(parts, fmt.Sprintf("%d pruned", len(result.pruned))) + } + if len(parts) == 0 { + return result.resource + ": no changes" + } + return result.resource + ": " + strings.Join(parts, ", ") +} + +func executeManagedCollect(projectRoot string, resources resourceSelection, dryRun, force bool) error { + result, err := collectManagedResources(projectRoot, resources, dryRun, force) + return renderManagedCollectResult(projectRoot, resources, dryRun, result, err) +} + +func renderManagedCollectResult(projectRoot string, resources resourceSelection, dryRun bool, result *sync.PullResult, collectErr error) error { + label := "Collecting resources" + if resources.rules && !resources.hooks { + label = "Collecting rules" + } else if resources.hooks && !resources.rules { + label = "Collecting hooks" + } + ui.Header(ui.WithModeLabel(label)) + + if result == nil { + result = &sync.PullResult{Failed: make(map[string]error)} + } + + if dryRun { + if len(result.Pulled) == 0 && len(result.Skipped) == 0 { + if collectErr == nil { + ui.Info("Dry run - no collectible managed resources found") + } + return collectErr + } + for _, name := range result.Pulled { + ui.ListItem("info", name, "would collect") + } + for _, name := range result.Skipped { + ui.ListItem("info", name, "would skip") + } + ui.Info("Dry run - no changes made") + return collectErr + } + + for _, name := range result.Pulled { + ui.StepDone(name, "collected into managed store") + } + for _, name := range result.Skipped { + ui.StepSkip(name, "already exists in managed store, use --force to overwrite") + } + if len(result.Pulled) > 0 { + showCollectNextSteps("skills", projectRoot) + } + return collectErr +} + +func collectManagedResources(projectRoot string, resources resourceSelection, dryRun, force bool) (*sync.PullResult, error) { + result := &sync.PullResult{Failed: make(map[string]error)} + var errs []error + + if resources.rules { + ruleResult, err := collectManagedRules(projectRoot, dryRun, force) + if err != nil { + result.Failed["rules"] = err + errs = append(errs, err) + } else { + result = mergePullResults(result, ruleResult) + } + } + if resources.hooks { + hookResult, err := collectManagedHooks(projectRoot, dryRun, force) + if err != nil { + result.Failed["hooks"] = err + errs = append(errs, err) + } else { + result = mergePullResults(result, hookResult) + } + } + + sort.Strings(result.Pulled) + sort.Strings(result.Skipped) + return result, combineCollectErrors(errs...) +} + +func collectManagedRules(projectRoot string, dryRun, force bool) (*sync.PullResult, error) { + items, _, err := inspect.ScanRules(projectRoot) + if err != nil { + return nil, fmt.Errorf("scan rules: %w", err) + } + + collectible := make([]inspect.RuleItem, 0, len(items)) + for _, item := range items { + if item.Collectible { + collectible = append(collectible, item) + } + } + if len(collectible) == 0 { + return &sync.PullResult{Failed: make(map[string]error)}, nil + } + + if dryRun { + preview, err := managed.PreviewCollectRules(projectRoot, collectible, force) + if err != nil { + return nil, err + } + return &sync.PullResult{ + Pulled: append([]string{}, preview.Pulled...), + Skipped: append([]string{}, preview.Skipped...), + Failed: make(map[string]error), + }, nil + } + + strategy := managedrules.StrategySkip + if force { + strategy = managedrules.StrategyOverwrite + } + collected, err := managed.CollectRules(projectRoot, collectible, strategy) + if err != nil { + return nil, err + } + return &sync.PullResult{ + Pulled: append(append([]string{}, collected.Created...), collected.Overwritten...), + Skipped: append([]string{}, collected.Skipped...), + Failed: make(map[string]error), + }, nil +} + +func collectManagedHooks(projectRoot string, dryRun, force bool) (*sync.PullResult, error) { + items, _, err := inspect.ScanHooks(projectRoot) + if err != nil { + return nil, fmt.Errorf("scan hooks: %w", err) + } + + collectible := make([]inspect.HookItem, 0, len(items)) + for _, item := range items { + if item.Collectible { + collectible = append(collectible, item) + } + } + if len(collectible) == 0 { + return &sync.PullResult{Failed: make(map[string]error)}, nil + } + + if dryRun { + preview, err := managed.PreviewCollectHooks(projectRoot, collectible, force) + if err != nil { + return nil, err + } + return &sync.PullResult{ + Pulled: append([]string{}, preview.Pulled...), + Skipped: append([]string{}, preview.Skipped...), + Failed: make(map[string]error), + }, nil + } + + strategy := managedhooks.StrategySkip + if force { + strategy = managedhooks.StrategyOverwrite + } + collected, err := managed.CollectHooks(projectRoot, collectible, strategy) + if err != nil { + return nil, err + } + return &sync.PullResult{ + Pulled: append(append([]string{}, collected.Created...), collected.Overwritten...), + Skipped: append([]string{}, collected.Skipped...), + Failed: make(map[string]error), + }, nil +} + +func resolveManagedRuleTarget(name string, target config.TargetConfig, projectRoot string) (string, string, bool) { + sc := target.SkillsConfig() + compileTarget, ok := resolveManagedRuleTool(name, sc.Path) + if !ok { + return "", "", false + } + if strings.TrimSpace(projectRoot) != "" { + return compileTarget, projectRoot, true + } + return compileTarget, managedRuleGlobalRoot(sc.Path), true +} + +func createSyncBackup(entry syncTargetEntry, resources resourceSelection) (string, error) { + if resources.includesManaged() { + plan, errs := syncBackupPlanForTarget(entry, resources) + for _, err := range errs { + ui.Warning("Backup planning for %s: %v", entry.name, err) + } + return backup.CreateSnapshot(entry.name, plan.paths, backup.SnapshotOptions{ + RestoreBaseMode: plan.restoreBaseMode, + TargetRelativePath: plan.targetRelativePath, + }) + } + return backup.Create(entry.name, entry.target.SkillsConfig().Path) +} + +func syncBackupPathsForTarget(entry syncTargetEntry, resources resourceSelection) ([]backup.SnapshotPath, []error) { + plan, errs := syncBackupPlanForTarget(entry, resources) + return plan.paths, errs +} + +type syncBackupPlan struct { + paths []backup.SnapshotPath + restoreBaseMode backup.SnapshotRestoreBaseMode + targetRelativePath string +} + +type syncBackupSource struct { + path string + followTopSymlinks bool +} + +func syncBackupPlanForTarget(entry syncTargetEntry, resources resourceSelection) (syncBackupPlan, []error) { + skillsTargetPath := entry.target.SkillsConfig().Path + plan := syncBackupPlan{ + paths: make([]backup.SnapshotPath, 0, 4), + } + errs := make([]error, 0, 2) + baseSources := make([]string, 0, 4) + if cleanedTarget := filepath.Clean(strings.TrimSpace(skillsTargetPath)); cleanedTarget != "" && cleanedTarget != "." { + baseSources = append(baseSources, cleanedTarget) + } + + snapshotSources := make([]syncBackupSource, 0, 4) + + if resources.skills { + if info, err := os.Lstat(skillsTargetPath); err == nil && info.Mode()&os.ModeSymlink == 0 { + snapshotSources = append(snapshotSources, syncBackupSource{ + path: skillsTargetPath, + followTopSymlinks: true, + }) + } + } + + if resources.rules { + rulePaths, err := managedRuleBackupSourcePaths(entry.name, entry.target, "") + if err != nil { + errs = append(errs, fmt.Errorf("rules: %w", err)) + } else { + baseSources = append(baseSources, rulePaths...) + for _, path := range rulePaths { + snapshotSources = append(snapshotSources, syncBackupSource{path: path}) + } + } + } + + if resources.hooks { + hookPaths, err := managedHookBackupSourcePaths(entry.name, entry.target, "") + if err != nil { + errs = append(errs, fmt.Errorf("hooks: %w", err)) + } else { + baseSources = append(baseSources, hookPaths...) + for _, path := range hookPaths { + snapshotSources = append(snapshotSources, syncBackupSource{path: path}) + } + } + } + + if len(snapshotSources) == 0 { + return plan, errs + } + + backupBasePath, restoreBaseMode, err := syncSnapshotBase(skillsTargetPath, baseSources) + if err != nil { + errs = append(errs, err) + return plan, errs + } + plan.restoreBaseMode = restoreBaseMode + targetRelativePath, err := snapshotPathRelativeToBase(backupBasePath, skillsTargetPath) + if err != nil { + errs = append(errs, fmt.Errorf("target path: %w", err)) + return plan, errs + } + plan.targetRelativePath = targetRelativePath.RelativePath + + for _, source := range snapshotSources { + path, err := snapshotPathRelativeToBase(backupBasePath, source.path) + if err != nil { + errs = append(errs, err) + continue + } + path.FollowTopSymlinks = source.followTopSymlinks + plan.paths = append(plan.paths, path) + } + + return plan, errs +} + +func syncSnapshotBase(targetPath string, sourcePaths []string) (string, backup.SnapshotRestoreBaseMode, error) { + cleanedTarget := filepath.Clean(strings.TrimSpace(targetPath)) + if cleanedTarget == "" || cleanedTarget == "." { + return "", "", fmt.Errorf("snapshot base: target path is required") + } + + commonBase, err := commonAncestorPath(sourcePaths) + if err != nil { + return "", "", fmt.Errorf("snapshot base: %w", err) + } + + switch { + case cleanedTarget == commonBase: + return cleanedTarget, backup.SnapshotRestoreBaseTarget, nil + case filepath.Dir(cleanedTarget) == commonBase: + return commonBase, backup.SnapshotRestoreBaseParent, nil + case filepath.Dir(filepath.Dir(cleanedTarget)) == commonBase: + return commonBase, backup.SnapshotRestoreBaseGrandparent, nil + default: + return "", "", fmt.Errorf("snapshot base %s is not representable for target %s", commonBase, cleanedTarget) + } +} + +func managedRuleBackupSourcePaths(name string, target config.TargetConfig, projectRoot string) ([]string, error) { + compileTarget, compileRoot, ok := resolveManagedRuleTarget(name, target, projectRoot) + if !ok { + return nil, nil + } + + store := managedrules.NewStore(projectRoot) + records, err := store.List() + if err != nil { + return nil, fmt.Errorf("list managed rules: %w", err) + } + files, _, err := managedrules.CompileTarget(records, compileTarget, name, compileRoot) + if err != nil { + if errors.Is(err, managedrules.ErrUnsupportedTarget) { + return nil, nil + } + return nil, fmt.Errorf("compile managed rules: %w", err) + } + + paths := make([]string, 0, len(files)+1) + if ownedDir, ok := managedRuleOwnedDir(compileTarget, compileRoot); ok { + paths = append(paths, ownedDir) + } + + for _, file := range files { + if ownedDir, ok := managedRuleOwnedDir(compileTarget, compileRoot); ok && pathWithinDir(file.Path, ownedDir) { + continue + } + paths = append(paths, file.Path) + } + + return paths, nil +} + +func managedHookBackupSourcePaths(name string, target config.TargetConfig, projectRoot string) ([]string, error) { + compileTarget, compileRoot, ok := managed.ResolveHookTarget(name, target, projectRoot) + if !ok { + return nil, nil + } + + store := managedhooks.NewStore(projectRoot) + records, err := store.List() + if err != nil { + return nil, fmt.Errorf("list managed hooks: %w", err) + } + rawConfig, err := managed.LoadHookRawConfig(compileTarget, compileRoot) + if err != nil { + return nil, fmt.Errorf("load managed hook config: %w", err) + } + files, _, err := managedhooks.CompileTarget(records, compileTarget, name, compileRoot, string(rawConfig)) + if err != nil { + return nil, fmt.Errorf("compile managed hooks: %w", err) + } + + paths := make([]string, 0, len(files)) + for _, file := range files { + paths = append(paths, file.Path) + } + return paths, nil +} + +func snapshotPathRelativeToBase(basePath, actualPath string) (backup.SnapshotPath, error) { + relative, err := filepath.Rel(basePath, actualPath) + if err != nil { + return backup.SnapshotPath{}, fmt.Errorf("rel snapshot path %s: %w", actualPath, err) + } + return backup.SnapshotPath{ + RelativePath: relative, + SourcePath: actualPath, + }, nil +} + +func commonAncestorPath(paths []string) (string, error) { + if len(paths) == 0 { + return "", fmt.Errorf("at least one snapshot path is required") + } + + ancestor := filepath.Clean(strings.TrimSpace(paths[0])) + if ancestor == "" { + return "", fmt.Errorf("snapshot path is required") + } + + for _, candidate := range paths[1:] { + cleaned := filepath.Clean(strings.TrimSpace(candidate)) + if cleaned == "" { + return "", fmt.Errorf("snapshot path is required") + } + ancestor = sharedAncestorPath(ancestor, cleaned) + } + + return ancestor, nil +} + +func sharedAncestorPath(a, b string) string { + ancestor := filepath.Clean(a) + candidate := filepath.Clean(b) + for ancestor != filepath.Dir(ancestor) { + if ancestor == candidate || pathWithinDir(candidate, ancestor) { + return ancestor + } + ancestor = filepath.Dir(ancestor) + } + if ancestor == candidate || pathWithinDir(candidate, ancestor) { + return ancestor + } + return filepath.Dir(ancestor) +} + +func resolveManagedRuleTool(name, targetPath string) (string, bool) { + for _, supported := range []string{"claude", "codex", "gemini"} { + if config.MatchesTargetName(supported, name) { + return supported, true + } + } + + switch managedRulePathFamily(targetPath) { + case "claude", "codex", "gemini": + return managedRulePathFamily(targetPath), true + default: + return "", false + } +} + +func managedRuleGlobalRoot(targetPath string) string { + cleaned := filepath.Clean(strings.TrimSpace(targetPath)) + if cleaned == "" || cleaned == "." { + return targetPath + } + if strings.EqualFold(filepath.Base(cleaned), "skills") { + return filepath.Dir(cleaned) + } + return cleaned +} + +func managedRulePathFamily(targetPath string) string { + cleaned := filepath.Clean(strings.TrimSpace(targetPath)) + if cleaned == "" || cleaned == "." { + return "" + } + + base := strings.ToLower(filepath.Base(cleaned)) + if base == "skills" { + base = strings.ToLower(filepath.Base(filepath.Dir(cleaned))) + } + + switch base { + case ".claude", "claude": + return "claude" + case ".codex", "codex", ".agents", "agents": + return "codex" + case ".gemini", "gemini": + return "gemini" + default: + return "" + } +} + +func pruneManagedRuleOrphans(target, root string, files []adapters.CompiledFile, dryRun bool) ([]string, error) { + ownedDir, ok := managedRuleOwnedDir(target, root) + if !ok { + return []string{}, nil + } + + info, err := os.Stat(ownedDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []string{}, nil + } + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("managed rules path is not a directory: %s", ownedDir) + } + + keep := make(map[string]struct{}, len(files)) + for _, file := range files { + if pathWithinDir(file.Path, ownedDir) { + keep[filepath.Clean(file.Path)] = struct{}{} + } + } + + pruned := make([]string, 0) + if err := filepath.WalkDir(ownedDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == ownedDir || d.IsDir() { + return nil + } + + cleaned := filepath.Clean(path) + if _, ok := keep[cleaned]; ok { + return nil + } + + pruned = append(pruned, cleaned) + if dryRun { + return nil + } + return os.Remove(cleaned) + }); err != nil { + return nil, err + } + + if dryRun { + return pruned, nil + } + return pruned, removeEmptyRuleSubdirs(ownedDir) +} + +func managedRuleOwnedDir(target, root string) (string, bool) { + cleaned := filepath.Clean(strings.TrimSpace(root)) + switch strings.ToLower(strings.TrimSpace(target)) { + case "claude": + if strings.EqualFold(filepath.Base(cleaned), ".claude") { + return filepath.Join(cleaned, "rules"), true + } + return filepath.Join(cleaned, ".claude", "rules"), true + case "gemini": + if strings.EqualFold(filepath.Base(cleaned), ".gemini") { + return filepath.Join(cleaned, "rules"), true + } + return filepath.Join(cleaned, ".gemini", "rules"), true + default: + return "", false + } +} + +func removeEmptyRuleSubdirs(root string) error { + var dirs []string + if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && path != root { + dirs = append(dirs, path) + } + return nil + }); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + + sort.Slice(dirs, func(i, j int) bool { + return len(dirs[i]) > len(dirs[j]) + }) + + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return err + } + if len(entries) == 0 { + if err := os.Remove(dir); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + } + return nil +} + +func pathWithinDir(path, dir string) bool { + rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(path)) + if err != nil { + return false + } + return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} diff --git a/cmd/skillshare/resource_flags.go b/cmd/skillshare/resource_flags.go new file mode 100644 index 00000000..d2da87b9 --- /dev/null +++ b/cmd/skillshare/resource_flags.go @@ -0,0 +1,115 @@ +package main + +import ( + "fmt" + "strings" +) + +type resourceSelection struct { + skills bool + rules bool + hooks bool +} + +type resourceFlagOptions struct { + defaultSelection resourceSelection + allowAll bool +} + +func parseResourceFlags(args []string, opts resourceFlagOptions) (resourceSelection, []string, error) { + selection := opts.defaultSelection + rest := make([]string, 0, len(args)) + + var sawResources bool + var sawAll bool + + for i := 0; i < len(args); i++ { + switch args[i] { + case "--resources": + if i+1 >= len(args) { + return resourceSelection{}, nil, fmt.Errorf("--resources requires a comma-separated value") + } + if !sawResources { + selection = resourceSelection{} + sawResources = true + } + if sawAll { + return resourceSelection{}, nil, fmt.Errorf("--all and --resources cannot be used together") + } + if err := selection.addCSV(args[i+1]); err != nil { + return resourceSelection{}, nil, err + } + i++ + case "--all": + if opts.allowAll { + if sawResources { + return resourceSelection{}, nil, fmt.Errorf("--all and --resources cannot be used together") + } + sawAll = true + selection = allResources() + continue + } + rest = append(rest, args[i]) + default: + rest = append(rest, args[i]) + } + } + + if !selection.any() { + return resourceSelection{}, nil, fmt.Errorf("at least one resource is required") + } + + return selection, rest, nil +} + +func allResources() resourceSelection { + return resourceSelection{skills: true, rules: true, hooks: true} +} + +func (s resourceSelection) any() bool { + return s.skills || s.rules || s.hooks +} + +func (s resourceSelection) includesManaged() bool { + return s.rules || s.hooks +} + +func (s resourceSelection) onlyManaged() bool { + return !s.skills && s.includesManaged() +} + +func (s resourceSelection) names() []string { + names := make([]string, 0, 3) + if s.skills { + names = append(names, "skills") + } + if s.rules { + names = append(names, "rules") + } + if s.hooks { + names = append(names, "hooks") + } + return names +} + +func (s *resourceSelection) addCSV(raw string) error { + for _, part := range strings.Split(raw, ",") { + value := strings.ToLower(strings.TrimSpace(part)) + switch value { + case "": + continue + case "skills": + s.skills = true + case "rules": + s.rules = true + case "hooks": + s.hooks = true + default: + return fmt.Errorf("unsupported resource %q", strings.TrimSpace(part)) + } + } + if !s.any() { + return fmt.Errorf("at least one resource is required") + } + return nil +} diff --git a/cmd/skillshare/resource_flags_test.go b/cmd/skillshare/resource_flags_test.go new file mode 100644 index 00000000..583ce5ef --- /dev/null +++ b/cmd/skillshare/resource_flags_test.go @@ -0,0 +1,92 @@ +package main + +import "testing" + +func TestParseResourceFlags(t *testing.T) { + defaultSelection := resourceSelection{skills: true} + + tests := []struct { + name string + args []string + opts resourceFlagOptions + want resourceSelection + wantRest []string + wantError string + }{ + { + name: "defaults to skills only", + args: []string{"--dry-run"}, + opts: resourceFlagOptions{defaultSelection: defaultSelection}, + want: resourceSelection{skills: true}, + wantRest: []string{"--dry-run"}, + }, + { + name: "parses explicit resources", + args: []string{"--resources", "rules,hooks", "--force"}, + opts: resourceFlagOptions{defaultSelection: defaultSelection}, + want: resourceSelection{rules: true, hooks: true}, + wantRest: []string{"--force"}, + }, + { + name: "parses repeated resources case insensitively", + args: []string{"--resources", "Rules", "--resources", "hooks"}, + opts: resourceFlagOptions{defaultSelection: defaultSelection}, + want: resourceSelection{rules: true, hooks: true}, + wantRest: []string{}, + }, + { + name: "sync all selects every resource", + args: []string{"--all", "--dry-run"}, + opts: resourceFlagOptions{defaultSelection: defaultSelection, allowAll: true}, + want: resourceSelection{skills: true, rules: true, hooks: true}, + wantRest: []string{"--dry-run"}, + }, + { + name: "rejects unknown resource", + args: []string{"--resources", "rules,unknown"}, + opts: resourceFlagOptions{defaultSelection: defaultSelection}, + wantError: `unsupported resource "unknown"`, + }, + { + name: "rejects missing resources value", + args: []string{"--resources"}, + opts: resourceFlagOptions{defaultSelection: defaultSelection}, + wantError: "--resources requires a comma-separated value", + }, + { + name: "rejects conflicting all and resources", + args: []string{"--all", "--resources", "skills"}, + opts: resourceFlagOptions{defaultSelection: defaultSelection, allowAll: true}, + wantError: "--all and --resources cannot be used together", + }, + { + name: "collect parser leaves target all flag alone", + args: []string{"--all", "--resources", "rules"}, + opts: resourceFlagOptions{defaultSelection: defaultSelection}, + want: resourceSelection{rules: true}, + wantRest: []string{"--all"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, rest, err := parseResourceFlags(tt.args, tt.opts) + if tt.wantError != "" { + if err == nil { + t.Fatalf("expected error %q, got nil", tt.wantError) + } + if err.Error() != tt.wantError { + t.Fatalf("error = %q, want %q", err.Error(), tt.wantError) + } + return + } + if err != nil { + t.Fatalf("parseResourceFlags() error = %v", err) + } + if got != tt.want { + t.Fatalf("selection = %#v, want %#v", got, tt.want) + } + assertStringSlice(t, "rest", rest, tt.wantRest) + }) + } +} diff --git a/cmd/skillshare/restore_tui.go b/cmd/skillshare/restore_tui.go index 6f263843..c691211c 100644 --- a/cmd/skillshare/restore_tui.go +++ b/cmd/skillshare/restore_tui.go @@ -85,6 +85,19 @@ func (i restoreVersionItem) Title() string { return i.version.Label } func (i restoreVersionItem) Description() string { + if i.version.Manifest { + switch { + case i.version.SkillCount > 0 && len(i.version.SnapshotPaths) > 1: + return fmt.Sprintf("%d skill(s), %d extra path(s), %s", + i.version.SkillCount, len(i.version.SnapshotPaths)-1, formatBytes(i.version.TotalSize)) + case i.version.SkillCount > 0: + return fmt.Sprintf("%d skill(s), %s", + i.version.SkillCount, formatBytes(i.version.TotalSize)) + default: + return fmt.Sprintf("%d path(s), %s", + len(i.version.SnapshotPaths), formatBytes(i.version.TotalSize)) + } + } if i.version.TotalSize < 0 { return fmt.Sprintf("%d skill(s)", i.version.SkillCount) } @@ -486,14 +499,21 @@ func (m restoreTUIModel) startRestore() (tea.Model, tea.Cmd) { cmd := func() tea.Msg { start := time.Now() - - var destPath string + resolvedTargetName := targetName + destPath := "" if isAgentBackupEntry(targetName) { destPath = resolveAgentBackupPath(targets, targetName) } else { - if tc, ok := targets[targetName]; ok { - destPath = tc.SkillsConfig().Path + targetCfgName, targetCfg, err := resolveConfiguredRestoreTarget(targets, targetName) + if err != nil { + return restoreDoneMsg{err: err} } + sc := targetCfg.SkillsConfig() + if sc.Path == "" { + return restoreDoneMsg{err: fmt.Errorf("target '%s' not found in config", targetName)} + } + resolvedTargetName = targetCfgName + destPath = sc.Path } if destPath == "" { return restoreDoneMsg{err: fmt.Errorf("target '%s' not found in config", targetName)} @@ -501,7 +521,7 @@ func (m restoreTUIModel) startRestore() (tea.Model, tea.Cmd) { backupPath := filepath.Dir(version.Dir) opts := backup.RestoreOptions{Force: true} - err := backup.RestoreToPath(backupPath, targetName, destPath, opts) + err := backup.RestoreToPath(backupPath, resolvedTargetName, destPath, opts) e := oplog.NewEntry("restore", statusFromErr(err), time.Since(start)) e.Args = map[string]any{"target": targetName, "from": version.Label, "via": "tui"} @@ -805,8 +825,11 @@ func (m restoreTUIModel) viewRestoreConfirm() string { fmt.Fprintf(&b, " Restore %s from backup %s?\n\n", m.selectedTarget, m.selectedVersion.Label) } - fmt.Fprintf(&b, " Skills: %d\n", m.selectedVersion.SkillCount) - // Read size from cache (populated async); never block in View() + if m.selectedVersion.Manifest && m.selectedVersion.SkillCount == 0 { + fmt.Fprintf(&b, " Paths: %d\n", len(m.selectedVersion.SnapshotPaths)) + } else { + fmt.Fprintf(&b, " Skills: %d\n", m.selectedVersion.SkillCount) + } if sz, ok := m.versionSizeCache[m.selectedVersion.Dir]; ok { fmt.Fprintf(&b, " Size: %s\n", formatBytes(sz)) } else if m.selectedVersion.TotalSize >= 0 { @@ -827,6 +850,11 @@ func (m restoreTUIModel) viewRestoreConfirm() string { if len(m.selectedVersion.SkillNames) > 10 { fmt.Fprintf(&b, " ... and %d more\n", len(m.selectedVersion.SkillNames)-10) } + } else if len(m.selectedVersion.SnapshotPaths) > 0 { + b.WriteString("\n Contents:\n") + for _, name := range m.selectedVersion.SnapshotPaths { + fmt.Fprintf(&b, " %s\n", name) + } } b.WriteString("\n ") @@ -883,7 +911,7 @@ func (m restoreTUIModel) renderTargetDetail(s backup.TargetBackupSummary) string row("Path: ", agentPath) row("Status: ", describeTargetState(agentPath)) } - } else if t, ok := m.targets[s.TargetName]; ok { + } else if _, t, ok := m.resolveConfiguredTarget(s.TargetName); ok { sc := t.SkillsConfig() row("Path: ", sc.Path) if sc.Mode != "" { @@ -897,29 +925,21 @@ func (m restoreTUIModel) renderTargetDetail(s backup.TargetBackupSummary) string row("Latest: ", fmt.Sprintf("%s (%s)", s.Latest.Format("2006-01-02 15:04:05"), timeAgo(s.Latest))) row("Oldest: ", fmt.Sprintf("%s (%s)", s.Oldest.Format("2006-01-02 15:04:05"), timeAgo(s.Oldest))) - // Preview skills from latest backup — read directory directly instead of - // calling ListBackupVersions (which would walk all versions + dirSize). - latestDir := filepath.Join(m.backupDir, s.Latest.Format("2006-01-02_15-04-05"), s.TargetName) - if skillEntries, err := os.ReadDir(latestDir); err == nil { - var skillNames []string - for _, se := range skillEntries { - if se.IsDir() { - skillNames = append(skillNames, se.Name()) - } - } - sort.Strings(skillNames) - - if len(skillNames) > 0 { + // Preview skills from latest backup + latestVersions, _ := backup.ListBackupVersions(m.backupDir, s.TargetName) + if len(latestVersions) > 0 { + latest := latestVersions[0] + if len(latest.SkillNames) > 0 { b.WriteString("\n") b.WriteString(theme.Dim().Render("── Latest backup skills ──────────────")) b.WriteString("\n") const maxPreview = 20 - show := skillNames + show := latest.SkillNames if len(show) > maxPreview { show = show[:maxPreview] } for _, name := range show { - desc := readSkillDescription(filepath.Join(latestDir, name)) + desc := readSkillDescription(filepath.Join(latest.SkillBaseDir, name)) if desc != "" { b.WriteString(lipgloss.NewStyle().Render(" " + name)) b.WriteString("\n") @@ -930,8 +950,8 @@ func (m restoreTUIModel) renderTargetDetail(s backup.TargetBackupSummary) string b.WriteString("\n") } } - if len(skillNames) > maxPreview { - b.WriteString(theme.Dim().Render(fmt.Sprintf(" ... and %d more", len(skillNames)-maxPreview))) + if len(latest.SkillNames) > maxPreview { + b.WriteString(theme.Dim().Render(fmt.Sprintf(" ... and %d more", len(latest.SkillNames)-maxPreview))) b.WriteString("\n") } } @@ -950,20 +970,26 @@ func (m restoreTUIModel) renderVersionDetail(v backup.BackupVersion) string { } row("Date: ", fmt.Sprintf("%s (%s)", v.Label, timeAgo(v.Timestamp))) - row("Skills: ", fmt.Sprintf("%d", v.SkillCount)) - if v.TotalSize >= 0 { + if v.Manifest && v.SkillCount == 0 { + row("Paths: ", fmt.Sprintf("%d", len(v.SnapshotPaths))) + } else { + row("Skills: ", fmt.Sprintf("%d", v.SkillCount)) + } + if sz, ok := m.versionSizeCache[v.Dir]; ok { + row("Size: ", formatBytes(sz)) + } else if v.TotalSize >= 0 { row("Size: ", formatBytes(v.TotalSize)) } else { row("Size: ", "calculating...") } - var diffPath string + diffPath := "" if isAgentBackupEntry(m.selectedTarget) { diffPath = resolveAgentBackupPath(m.targets, m.selectedTarget) - } else if t, ok := m.targets[m.selectedTarget]; ok { + } else if _, t, ok := m.resolveConfiguredTarget(m.selectedTarget); ok { diffPath = t.SkillsConfig().Path } - if diffPath != "" { + if diffPath != "" && len(v.SkillNames) > 0 { added, removed, common := diffSkillSets(v.SkillNames, listDirNames(diffPath)) if len(added) > 0 || len(removed) > 0 { b.WriteString("\n") @@ -1004,33 +1030,49 @@ func (m restoreTUIModel) renderVersionDetail(v backup.BackupVersion) string { b.WriteString("\n") const maxDetail = 20 for i, name := range v.SkillNames { - if i < maxDetail { - desc := readSkillDescription(filepath.Join(v.Dir, name)) - files := listSkillFiles(filepath.Join(v.Dir, name)) - b.WriteString(lipgloss.NewStyle().Render(" " + name)) - b.WriteString("\n") - if desc != "" { - b.WriteString(theme.Dim().Render(" " + truncateStr(desc, 60))) - b.WriteString("\n") - } - if len(files) > 0 { - b.WriteString(theme.Dim().Render(" " + strings.Join(files, " "))) - b.WriteString("\n") - } - } else { + if i >= maxDetail { b.WriteString(theme.Dim().Render(" " + name)) b.WriteString("\n") + continue + } + desc := readSkillDescription(filepath.Join(v.SkillBaseDir, name)) + files := listSkillFiles(filepath.Join(v.SkillBaseDir, name)) + b.WriteString(lipgloss.NewStyle().Render(" " + name)) + b.WriteString("\n") + if desc != "" { + b.WriteString(theme.Dim().Render(" " + truncateStr(desc, 60))) + b.WriteString("\n") + } + if len(files) > 0 { + b.WriteString(theme.Dim().Render(" " + strings.Join(files, " "))) + b.WriteString("\n") } } if len(v.SkillNames) > maxDetail { b.WriteString(theme.Dim().Render(fmt.Sprintf(" ... %d skill(s) above shown without details", len(v.SkillNames)-maxDetail))) b.WriteString("\n") } + } else if len(v.SnapshotPaths) > 0 { + b.WriteString("\n") + b.WriteString(theme.Dim().Render("── Contents ──────────────────────────")) + b.WriteString("\n") + for _, name := range v.SnapshotPaths { + b.WriteString(theme.Dim().Render(" " + name)) + b.WriteString("\n") + } } return b.String() } +func (m restoreTUIModel) resolveConfiguredTarget(name string) (string, config.TargetConfig, bool) { + resolvedName, targetCfg, err := resolveConfiguredRestoreTarget(m.targets, name) + if err != nil || targetCfg.SkillsConfig().Path == "" { + return "", config.TargetConfig{}, false + } + return resolvedName, targetCfg, true +} + // --- Helpers --- // timeAgo returns a human-readable relative time string like "5m ago". diff --git a/cmd/skillshare/restore_tui_test.go b/cmd/skillshare/restore_tui_test.go new file mode 100644 index 00000000..ed159f67 --- /dev/null +++ b/cmd/skillshare/restore_tui_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "skillshare/internal/backup" + "skillshare/internal/config" +) + +func TestRestoreTUIRenderTargetDetail_ResolvesAlternateConfiguredTarget(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + backupDir := filepath.Join(t.TempDir(), "backups") + mustAddSkill(t, filepath.Join(backupDir, "2025-03-20_18-45-00", "universal"), "alpha") + + model := newRestoreTUIModel(nil, backupDir, map[string]config.TargetConfig{ + "agents": {Path: filepath.Join(home, ".agents", "skills")}, + }, "") + + detail := model.renderTargetDetail(backup.TargetBackupSummary{ + TargetName: "universal", + BackupCount: 1, + Latest: time.Date(2025, 3, 20, 18, 45, 0, 0, time.Local), + Oldest: time.Date(2025, 3, 20, 18, 45, 0, 0, time.Local), + }) + + if !strings.Contains(detail, filepath.Join(home, ".agents", "skills")) { + t.Fatalf("renderTargetDetail() missing resolved target path:\n%s", detail) + } + if !strings.Contains(detail, "Status:") { + t.Fatalf("renderTargetDetail() missing target status:\n%s", detail) + } +} + +func TestRestoreTUIRenderVersionDetail_ResolvesAlternateConfiguredTargetForDiff(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + targetPath := filepath.Join(home, ".agents", "skills") + mustAddSkill(t, targetPath, "local") + + model := newRestoreTUIModel(nil, t.TempDir(), map[string]config.TargetConfig{ + "agents": {Path: targetPath}, + }, "") + model.selectedTarget = "universal" + + detail := model.renderVersionDetail(backup.BackupVersion{ + Label: "2025-03-20 18:45:00", + Timestamp: time.Date(2025, 3, 20, 18, 45, 0, 0, time.Local), + SkillCount: 1, + SkillNames: []string{"alpha"}, + }) + + if !strings.Contains(detail, "Diff vs current target") { + t.Fatalf("renderVersionDetail() missing diff section:\n%s", detail) + } + if !strings.Contains(detail, "Restore:") { + t.Fatalf("renderVersionDetail() missing restore diff:\n%s", detail) + } + if !strings.Contains(detail, "Remove:") { + t.Fatalf("renderVersionDetail() missing remove diff:\n%s", detail) + } +} diff --git a/cmd/skillshare/sync.go b/cmd/skillshare/sync.go index 559d0bf0..179db51c 100644 --- a/cmd/skillshare/sync.go +++ b/cmd/skillshare/sync.go @@ -7,7 +7,6 @@ import ( "strings" "time" - "skillshare/internal/backup" "skillshare/internal/config" "skillshare/internal/oplog" "skillshare/internal/skillignore" @@ -23,6 +22,7 @@ type syncLogStats struct { DryRun bool Force bool ProjectScope bool + Resources []string } // syncJSONOutput is the JSON representation for sync --json output. @@ -55,6 +55,24 @@ type syncModeStats struct { linked, local, updated, pruned int } +func syncResultsForSkillError(entries []syncTargetEntry, err error) ([]syncTargetResult, int) { + if err == nil { + return nil, 0 + } + + results := make([]syncTargetResult, len(entries)) + for i, entry := range entries { + results[i] = syncTargetResult{ + name: entry.name, + mode: entry.mode, + include: entry.target.Include, + exclude: entry.target.Exclude, + errMsg: err.Error(), + } + } + return results, len(entries) +} + func cmdSync(args []string) error { if wantsHelp(args) { printSyncHelp() @@ -66,18 +84,6 @@ func cmdSync(args []string) error { return cmdSyncExtras(args[1:]) } - // Extract --all flag before mode parsing - hasAll := false - var filteredArgs []string - for _, a := range args { - if a == "--all" { - hasAll = true - } else { - filteredArgs = append(filteredArgs, a) - } - } - args = filteredArgs - start := time.Now() mode, rest, err := parseModeArgs(args) @@ -100,8 +106,33 @@ func cmdSync(args []string) error { applyModeLabel(mode) - // Extract kind filter (e.g. "skillshare sync agents"). kind, rest := parseKindArg(rest) + fullSync := hasArg(rest, "--all") + if fullSync { + if hasArg(rest, "--resources") { + return fmt.Errorf("--all and --resources cannot be used together") + } + rest = removeArg(rest, "--all") + kind = kindAll + } + + if kind == kindAgents && hasArg(rest, "--resources") { + return fmt.Errorf("--resources cannot be used with agents") + } + + resources := resourceSelection{skills: true} + if kind != kindAgents { + if fullSync { + resources = allResources() + } else { + resources, rest, err = parseResourceFlags(rest, resourceFlagOptions{ + defaultSelection: resourceSelection{skills: true}, + }) + if err != nil { + return err + } + } + } dryRun, force, jsonOutput := parseSyncFlags(rest) @@ -114,13 +145,17 @@ func cmdSync(args []string) error { } if mode == modeProject { - // Agent-only project sync if kind == kindAgents { - return syncAgentsProject(cwd, dryRun, force, jsonOutput, start) + err := syncAgentsProject(cwd, dryRun, force, jsonOutput, start) + logSyncOp(config.ProjectConfigPath(cwd), syncLogStats{ + DryRun: dryRun, + Force: force, + ProjectScope: true, + }, start, err) + return err } - if hasAll && !jsonOutput { - // Run project extras sync after project skills sync (text mode) + if fullSync && !jsonOutput { defer func() { if extrasErr := cmdSyncExtras(append([]string{"-p"}, rest...)); extrasErr != nil { ui.Warning("Extras sync: %v", extrasErr) @@ -128,19 +163,19 @@ func cmdSync(args []string) error { }() } - stats, results, projIgnoreStats, err := cmdSyncProject(cwd, dryRun, force, jsonOutput) + stats, results, projIgnoreStats, err := cmdSyncProject(cwd, resources, dryRun, force, jsonOutput) stats.ProjectScope = true - logSyncOp(config.ProjectConfigPath(cwd), stats, start, err) - // Append agent sync when kind=all or --all - if kind == kindAll || hasAll { + if kind == kindAll { if agentErr := syncAgentsProject(cwd, dryRun, force, jsonOutput, start); agentErr != nil && err == nil { err = agentErr } } + logSyncOp(config.ProjectConfigPath(cwd), stats, start, err) + if jsonOutput { - if hasAll { + if fullSync { projCfg, loadErr := config.LoadProject(cwd) if loadErr == nil && len(projCfg.Extras) > 0 { agentPaths := collectAgentTargetPathsProject(cwd) @@ -163,8 +198,19 @@ func cmdSync(args []string) error { return err } - // Validate config before sync - warnings, validErr := config.ValidateConfig(cfg) + if kind == kindAgents { + _, agentErr := syncAgentsGlobal(cfg, dryRun, force, jsonOutput, start) + logSyncOp(config.ConfigPath(), syncLogStats{ + Targets: len(cfg.Targets), + DryRun: dryRun, + Force: force, + }, start, agentErr) + return agentErr + } + + // Validate config before sync, but allow managed-resource syncs to + // continue past source/skills-path issues so partial execution can happen. + warnings, validErr := validateConfigForSync(cfg, resources) if validErr != nil { if jsonOutput { return writeJSONError(validErr) @@ -176,58 +222,97 @@ func cmdSync(args []string) error { ui.Warning("%s", w) } } - - // Agent-only mode: skip skill discovery/sync entirely - if kind == kindAgents { - _, agentErr := syncAgentsGlobal(cfg, dryRun, force, jsonOutput, start) - logSyncOp(config.ConfigPath(), syncLogStats{DryRun: dryRun, Force: force}, start, agentErr) - return agentErr + var entries []syncTargetEntry + for name, target := range cfg.Targets { + entries = append(entries, syncTargetEntry{name: name, target: target, mode: getTargetMode(target.SkillsConfig().Mode, cfg.Mode)}) } - // Phase 1: Discovery (skills) - var spinner *ui.Spinner - if !jsonOutput { - spinner = ui.StartSpinner("Discovering skills") - } - discoveredSkills, ignoreStats, discoverErr := sync.DiscoverSourceSkillsWithStats(cfg.Source) - if discoverErr != nil { - if spinner != nil { - spinner.Fail("Discovery failed") - } - if jsonOutput { - return writeJSONError(discoverErr) + var discoveredSkills []sync.DiscoveredSkill + var ignoreStats *skillignore.IgnoreStats + var skillSyncErr error + if resources.skills { + // Ensure source exists + if _, err := os.Stat(cfg.Source); os.IsNotExist(err) { + sourceErr := fmt.Errorf("source directory does not exist: %s", cfg.Source) + if !resources.includesManaged() { + if jsonOutput { + return writeJSONError(sourceErr) + } + return sourceErr + } + skillSyncErr = sourceErr + } else { + var spinner *ui.Spinner + if !jsonOutput { + spinner = ui.StartSpinner("Discovering skills") + } + discoveredSkills, ignoreStats, err = sync.DiscoverSourceSkillsWithStats(cfg.Source) + if err != nil { + if spinner != nil { + spinner.Fail("Discovery failed") + } + if !resources.includesManaged() { + if jsonOutput { + return writeJSONError(err) + } + return err + } + skillSyncErr = err + } else if spinner != nil { + spinner.Success(fmt.Sprintf("Discovered %d skills", len(discoveredSkills))) + reportCollisions(discoveredSkills, cfg.Targets) + } + if err == nil && len(discoveredSkills) == 0 { + warnings = append(warnings, "source directory is empty (0 skills)") + } } - return discoverErr - } - if spinner != nil { - spinner.Success(fmt.Sprintf("Discovered %d skills", len(discoveredSkills))) - reportCollisions(discoveredSkills, cfg.Targets) } - // Backup targets before sync (only if not dry-run and there are skills) - if !dryRun && len(discoveredSkills) > 0 && !jsonOutput { - backupTargetsBeforeSync(cfg) + if !dryRun && !jsonOutput { + shouldBackup := resources.includesManaged() + if !shouldBackup && skillSyncErr == nil && len(discoveredSkills) > 0 { + shouldBackup = true + } + if shouldBackup { + fmt.Println() + backupResources := resources + backupResources.skills = resources.skills && skillSyncErr == nil + backupTargetsBeforeSync(entries, backupResources) + } } - // Phase 2: Per-target sync (parallel) if !jsonOutput { - ui.Header("Syncing skills") + switch { + case resources.skills && resources.includesManaged(): + ui.Header("Syncing skills and resources") + case resources.skills: + ui.Header("Syncing skills") + default: + ui.Header("Syncing resources") + } if dryRun { ui.Warning("Dry run mode - no changes will be made") } } - var entries []syncTargetEntry - for name, target := range cfg.Targets { - entries = append(entries, syncTargetEntry{name: name, target: target, mode: getTargetMode(target.SkillsConfig().Mode, cfg.Mode)}) - } - var results []syncTargetResult var failedTargets int - if jsonOutput { - results, failedTargets = runParallelSyncQuiet(entries, cfg.Source, discoveredSkills, dryRun, force, "") - } else { - results, failedTargets = runParallelSync(entries, cfg.Source, discoveredSkills, dryRun, force, "") + if resources.skills { + if skillSyncErr != nil { + results, failedTargets = syncResultsForSkillError(entries, skillSyncErr) + } else if jsonOutput || resources.includesManaged() { + results, failedTargets = runParallelSyncQuiet(entries, cfg.Source, discoveredSkills, dryRun, force, "") + } else { + results, failedTargets = runParallelSync(entries, cfg.Source, discoveredSkills, dryRun, force, "") + } + } + if resources.includesManaged() { + var managedFailed int + results, managedFailed = syncManagedResourcesForEntries(entries, entries, results, resources, "", dryRun) + failedTargets += managedFailed + if !jsonOutput { + renderSyncResults(results) + } } var syncErr error @@ -268,15 +353,22 @@ func cmdSync(args []string) error { // Sync only manages symlinks — it must not prune registry entries // for installed skills whose files may be missing from disk. + if kind == kindAll { + if _, agentErr := syncAgentsGlobal(cfg, dryRun, force, jsonOutput, start); agentErr != nil && syncErr == nil { + syncErr = agentErr + } + } + logSyncOp(config.ConfigPath(), syncLogStats{ - Targets: len(cfg.Targets), - Failed: failedTargets, - DryRun: dryRun, - Force: force, + Targets: len(cfg.Targets), + Failed: failedTargets, + DryRun: dryRun, + Force: force, + Resources: resources.names(), }, start, syncErr) if jsonOutput { - if hasAll && len(cfg.Extras) > 0 { + if fullSync && len(cfg.Extras) > 0 { agentPaths := collectAgentTargetPathsGlobal(cfg) extrasEntries := runExtrasSyncEntries(cfg.Extras, func(extra config.ExtraConfig) string { return config.ResolveExtrasSourceDir(extra, cfg.ExtrasSource, cfg.Source) @@ -286,19 +378,11 @@ func cmdSync(args []string) error { return syncOutputJSON(results, dryRun, start, ignoreStats, syncErr) } - // Agent sync when kind=all or --all (after skill sync) - if kind == kindAll || hasAll { - if _, agentErr := syncAgentsGlobal(cfg, dryRun, force, jsonOutput, start); agentErr != nil && syncErr == nil { - syncErr = agentErr - } - } - - if hasAll { + if fullSync { if extrasErr := cmdSyncExtras(append([]string{"-g"}, rest...)); extrasErr != nil { ui.Warning("Extras sync: %v", extrasErr) } } - return syncErr } @@ -316,6 +400,16 @@ func parseSyncFlags(args []string) (dryRun, force, jsonOutput bool) { return dryRun, force, jsonOutput } +func removeArg(args []string, target string) []string { + rest := make([]string, 0, len(args)) + for _, arg := range args { + if arg != target { + rest = append(rest, arg) + } + } + return rest +} + func logSyncOp(cfgPath string, stats syncLogStats, start time.Time, cmdErr error) { status := statusFromErr(cmdErr) if stats.Failed > 0 && stats.Failed < stats.Targets { @@ -327,6 +421,7 @@ func logSyncOp(cfgPath string, stats syncLogStats, start time.Time, cmdErr error "targets_failed": stats.Failed, "dry_run": stats.DryRun, "force": stats.Force, + "resources": stats.Resources, "scope": "global", } if stats.ProjectScope { @@ -425,37 +520,18 @@ func syncOutputJSON(results []syncTargetResult, dryRun bool, start time.Time, iS return writeJSONResult(&output, syncErr) } -func backupTargetsBeforeSync(cfg *config.Config) { +func backupTargetsBeforeSync(entries []syncTargetEntry, resources resourceSelection) { backedUp := false - for name, target := range cfg.Targets { - backupPath, err := backup.Create(name, target.SkillsConfig().Path) + for _, entry := range entries { + backupPath, err := createSyncBackup(entry, resources) if err != nil { - ui.Warning("Failed to backup %s: %v", name, err) + ui.Warning("Failed to backup %s: %v", entry.name, err) } else if backupPath != "" { if !backedUp { ui.Header("Backing up") backedUp = true } - ui.Success("%s -> %s", name, backupPath) - } - } - - // Also backup agent targets if any exist. - backupDir, agentTargets, err := resolveGlobalAgentBackupContextFromCfg(cfg) - if err != nil || len(agentTargets) == 0 { - return - } - for _, at := range agentTargets { - entryName := at.name + "-agents" - bp, bErr := backup.CreateInDir(backupDir, entryName, at.agentPath) - if bErr != nil { - ui.Warning("Failed to backup %s: %v", entryName, bErr) - } else if bp != "" { - if !backedUp { - ui.Header("Backing up") - backedUp = true - } - ui.Success("%s -> %s", entryName, bp) + ui.Success("%s -> %s", entry.name, backupPath) } } } @@ -818,10 +894,11 @@ func syncSymlinkMode(name string, target config.TargetConfig, source string, dry func printSyncHelp() { fmt.Println(`Usage: skillshare sync [agents] [options] -Sync skills from source to all configured targets. +Sync skills and managed resources from source to configured targets. Options: - --all Sync skills, agents, and extras + --resources LIST Sync only specific resources: skills,rules,hooks + --all Full sync: skills, rules, hooks, agents, and extras --dry-run, -n Preview changes without applying --force, -f Force sync (overwrite local changes) --json Output results as JSON @@ -829,13 +906,11 @@ Options: --global, -g Use global config --help, -h Show this help -Subcommands: - extras Sync only extras (see: skillshare sync extras --help) - Examples: skillshare sync Sync skills to all targets + skillshare sync --all Full sync including managed resources, agents, and extras + skillshare sync --resources rules,hooks skillshare sync --dry-run Preview sync changes - skillshare sync --all Sync skills, agents, and extras skillshare sync -p Sync project-level skills skillshare sync agents Sync agents only`) } diff --git a/cmd/skillshare/sync_parallel.go b/cmd/skillshare/sync_parallel.go index c397196e..24309fe1 100644 --- a/cmd/skillshare/sync_parallel.go +++ b/cmd/skillshare/sync_parallel.go @@ -348,11 +348,10 @@ func renderSyncResults(results []syncTargetResult) { for _, r := range results { if r.errMsg != "" { ui.Error("%s: %s", r.name, r.errMsg) - continue + } else { + ui.Success("%s: %s", r.name, r.message) } - ui.Success("%s: %s", r.name, r.message) - if len(r.include) > 0 { ui.Info(" include: %s", strings.Join(r.include, ", ")) } diff --git a/cmd/skillshare/sync_project.go b/cmd/skillshare/sync_project.go index 08100ceb..81501618 100644 --- a/cmd/skillshare/sync_project.go +++ b/cmd/skillshare/sync_project.go @@ -12,12 +12,13 @@ import ( "skillshare/internal/ui" ) -func cmdSyncProject(root string, dryRun, force, jsonOutput bool) (syncLogStats, []syncTargetResult, *skillignore.IgnoreStats, error) { +func cmdSyncProject(root string, resources resourceSelection, dryRun, force, jsonOutput bool) (syncLogStats, []syncTargetResult, *skillignore.IgnoreStats, error) { start := time.Now() stats := syncLogStats{ DryRun: dryRun, Force: force, ProjectScope: true, + Resources: resources.names(), } if !projectConfigExists(root) { @@ -31,49 +32,6 @@ func cmdSyncProject(root string, dryRun, force, jsonOutput bool) (syncLogStats, return stats, nil, nil, err } stats.Targets = len(runtime.config.Targets) - - // Validate project config before sync - warnings, validErr := config.ValidateProjectConfig(runtime.config, root) - if validErr != nil { - return stats, nil, nil, validErr - } - if !jsonOutput { - for _, w := range warnings { - ui.Warning("%s", w) - } - } - - // ValidateProjectConfig warns on missing source (may not exist yet after init). - // Gate here as a hard error — sync cannot proceed without source skills. - if _, err := os.Stat(runtime.sourcePath); os.IsNotExist(err) { - return stats, nil, nil, fmt.Errorf("source directory does not exist: %s", runtime.sourcePath) - } - - // Phase 1: Discovery - var spinner *ui.Spinner - if !jsonOutput { - spinner = ui.StartSpinner("Discovering skills") - } - discoveredSkills, ignoreStats, discoverErr := sync.DiscoverSourceSkillsWithStats(runtime.sourcePath) - if discoverErr != nil { - if spinner != nil { - spinner.Fail("Discovery failed") - } - return stats, nil, nil, discoverErr - } - if spinner != nil { - spinner.Success(fmt.Sprintf("Discovered %d skills", len(discoveredSkills))) - reportCollisions(discoveredSkills, runtime.targets) - } - - // Phase 2: Per-target sync - if !jsonOutput { - ui.Header("Syncing skills (project)") - if dryRun { - ui.Warning("Dry run mode - no changes will be made") - } - } - var entries []syncTargetEntry notFoundCount := 0 for _, entry := range runtime.config.Targets { @@ -93,12 +51,69 @@ func cmdSyncProject(root string, dryRun, force, jsonOutput bool) (syncLogStats, entries = append(entries, syncTargetEntry{name: name, target: target, mode: mode}) } + var discoveredSkills []sync.DiscoveredSkill + var ignoreStats *skillignore.IgnoreStats + var skillSyncErr error + if resources.skills { + if _, err := os.Stat(runtime.sourcePath); os.IsNotExist(err) { + sourceErr := fmt.Errorf("source directory does not exist: %s", runtime.sourcePath) + if !resources.includesManaged() { + return stats, nil, nil, sourceErr + } + skillSyncErr = sourceErr + } else { + var spinner *ui.Spinner + if !jsonOutput { + spinner = ui.StartSpinner("Discovering skills") + } + discoveredSkills, ignoreStats, err = sync.DiscoverSourceSkillsWithStats(runtime.sourcePath) + if err != nil { + if spinner != nil { + spinner.Fail("Discovery failed") + } + if !resources.includesManaged() { + return stats, nil, nil, err + } + skillSyncErr = err + } else if spinner != nil { + spinner.Success(fmt.Sprintf("Discovered %d skills", len(discoveredSkills))) + reportCollisions(discoveredSkills, runtime.targets) + } + } + } + + if !jsonOutput { + switch { + case resources.skills && resources.includesManaged(): + ui.Header("Syncing skills and resources (project)") + case resources.skills: + ui.Header("Syncing skills (project)") + default: + ui.Header("Syncing resources (project)") + } + if dryRun { + ui.Warning("Dry run mode - no changes will be made") + } + } + var results []syncTargetResult var failedTargets int - if jsonOutput { - results, failedTargets = runParallelSyncQuiet(entries, runtime.sourcePath, discoveredSkills, dryRun, force, root) - } else { - results, failedTargets = runParallelSync(entries, runtime.sourcePath, discoveredSkills, dryRun, force, root) + if resources.skills { + if skillSyncErr != nil { + results, failedTargets = syncResultsForSkillError(entries, skillSyncErr) + } else if jsonOutput || resources.includesManaged() { + results, failedTargets = runParallelSyncQuiet(entries, runtime.sourcePath, discoveredSkills, dryRun, force, root) + } else { + results, failedTargets = runParallelSync(entries, runtime.sourcePath, discoveredSkills, dryRun, force, root) + } + } + if resources.includesManaged() { + var managedFailed int + results, managedFailed = syncManagedResourcesForEntries(entries, entries, results, resources, root, dryRun) + failedTargets += managedFailed + if !jsonOutput { + renderSyncResults(results) + } } failedTargets += notFoundCount @@ -126,8 +141,6 @@ func cmdSyncProject(root string, dryRun, force, jsonOutput bool) (syncLogStats, printIgnoredSkills(ignoreStats) } - // Reconcile registry and cleanup regardless of target failures. - // Registry cleanup only depends on source disk state, not target sync results. if !dryRun { if n, _ := trash.Cleanup(trash.ProjectTrashDir(root), 0); n > 0 { if !jsonOutput { diff --git a/cmd/skillshare/sync_resources_test.go b/cmd/skillshare/sync_resources_test.go new file mode 100644 index 00000000..07ece5a5 --- /dev/null +++ b/cmd/skillshare/sync_resources_test.go @@ -0,0 +1,805 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "skillshare/internal/backup" + "skillshare/internal/config" + managedhooks "skillshare/internal/resources/hooks" + managedrules "skillshare/internal/resources/rules" + "skillshare/internal/ui" +) + +func TestCmdSync_DefaultStillSyncsSkillsOnly(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + sourceDir := filepath.Join(t.TempDir(), "source") + mustAddSkill(t, sourceDir, "alpha") + + cfg := &config.Config{ + Source: sourceDir, + Targets: map[string]config.TargetConfig{ + "claude": {Path: filepath.Join(home, ".claude", "skills")}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + putManagedRule(t, "", "claude/manual.md", "# Managed rule\n") + putManagedHook(t, "", "claude/pre-tool-use/bash.yaml", "./bin/check") + + resetModeLabel(t) + if err := cmdSync(nil); err != nil { + t.Fatalf("cmdSync() error = %v", err) + } + + mustExist(t, filepath.Join(home, ".claude", "skills", "alpha")) + mustNotExist(t, filepath.Join(home, ".claude", "rules", "manual.md")) + mustNotExist(t, filepath.Join(home, ".claude", "settings.json")) +} + +func TestCmdSync_AllIncludesManagedResources(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + sourceDir := filepath.Join(t.TempDir(), "source") + mustAddSkill(t, sourceDir, "alpha") + + cfg := &config.Config{ + Source: sourceDir, + Targets: map[string]config.TargetConfig{ + "claude": {Path: filepath.Join(home, ".claude", "skills")}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + putManagedRule(t, "", "claude/manual.md", "# Managed rule\n") + putManagedHook(t, "", "claude/pre-tool-use/bash.yaml", "./bin/check") + + resetModeLabel(t) + if err := cmdSync([]string{"--all"}); err != nil { + t.Fatalf("cmdSync(--all) error = %v", err) + } + + mustExist(t, filepath.Join(home, ".claude", "skills", "alpha")) + mustExist(t, filepath.Join(home, ".claude", "rules", "manual.md")) + mustExist(t, filepath.Join(home, ".claude", "settings.json")) +} + +func TestCmdSync_AllRendersManagedResourceOutput(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + sourceDir := filepath.Join(t.TempDir(), "source") + mustAddSkill(t, sourceDir, "alpha") + + cfg := &config.Config{ + Source: sourceDir, + Targets: map[string]config.TargetConfig{ + "claude": {Path: filepath.Join(home, ".claude", "skills")}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + putManagedRule(t, "", "claude/manual.md", "# Managed rule\n") + putManagedHook(t, "", "claude/pre-tool-use/bash.yaml", "./bin/check") + + resetModeLabel(t) + output := captureStdout(t, func() { + if err := cmdSync([]string{"--all"}); err != nil { + t.Fatalf("cmdSync(--all) error = %v", err) + } + }) + + if !strings.Contains(output, "rules: 1 updated") { + t.Fatalf("combined sync output missing rules detail:\n%s", output) + } + if !strings.Contains(output, "hooks: 1 updated") { + t.Fatalf("combined sync output missing hooks detail:\n%s", output) + } +} + +func TestCmdSync_ManagedOnlyDoesNotRequireSkillsSource(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + + cfg := &config.Config{ + Source: filepath.Join(t.TempDir(), "missing-source"), + Targets: map[string]config.TargetConfig{ + "claude": {Path: filepath.Join(home, ".claude", "skills")}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + putManagedRule(t, "", "claude/manual.md", "# Managed rule\n") + putManagedHook(t, "", "claude/pre-tool-use/bash.yaml", "./bin/check") + + resetModeLabel(t) + if err := cmdSync([]string{"--resources", "rules,hooks"}); err != nil { + t.Fatalf("cmdSync(--resources rules,hooks) error = %v", err) + } + + mustExist(t, filepath.Join(home, ".claude", "rules", "manual.md")) + mustExist(t, filepath.Join(home, ".claude", "settings.json")) +} + +func TestCmdSync_ManagedRulesFailureStillAttemptsHooks(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + cfg := &config.Config{ + Source: filepath.Join(t.TempDir(), "unused-source"), + Targets: map[string]config.TargetConfig{ + "claude": {Path: filepath.Join(home, ".claude", "skills")}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + putManagedRule(t, "", "claude/manual.md", "# Managed rule\n") + putManagedHook(t, "", "claude/pre-tool-use/bash.yaml", "./bin/check") + mustWriteFile(t, filepath.Join(home, ".claude", "rules"), "not-a-directory") + + resetModeLabel(t) + var syncErr error + output := captureStdout(t, func() { + syncErr = cmdSync([]string{"--resources", "rules,hooks"}) + }) + if syncErr == nil { + t.Fatal("expected cmdSync(--resources rules,hooks) to report partial failure") + } + + mustExist(t, filepath.Join(home, ".claude", "settings.json")) + if !strings.Contains(output, "hooks: 1 updated") { + t.Fatalf("partial managed sync output missing hooks detail:\n%s", output) + } + if !strings.Contains(output, "apply managed rules") { + t.Fatalf("partial managed sync output missing rules failure:\n%s", output) + } +} + +func TestCmdCollect_ManagedDryRunReturnsPartialFailure(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + mustWriteFile(t, filepath.Join(home, ".claude", "CLAUDE.md"), "# Root rule\n") + mustWriteFile(t, filepath.Join(home, ".claude", "rules", "CLAUDE.md"), "# Nested rule\n") + mustWriteFile(t, filepath.Join(home, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./bin/check"}]}]}}`) + + resetModeLabel(t) + var collectErr error + output := captureStdout(t, func() { + collectErr = cmdCollect([]string{"--resources", "rules,hooks", "--dry-run"}) + }) + if collectErr == nil { + t.Fatal("expected cmdCollect(--resources rules,hooks --dry-run) to report partial failure") + } + if !strings.Contains(output, "would collect") { + t.Fatalf("dry-run collect output missing planned hook/rule details:\n%s", output) + } +} + +func TestCmdSync_SkillsFailureStillAttemptsManagedResources(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + sourceDir := filepath.Join(t.TempDir(), "source") + mustAddSkill(t, sourceDir, "alpha") + + cfg := &config.Config{ + Source: sourceDir, + Targets: map[string]config.TargetConfig{ + "claude": {Path: filepath.Join(home, ".claude", "skills")}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + putManagedRule(t, "", "claude/manual.md", "# Managed rule\n") + putManagedHook(t, "", "claude/pre-tool-use/bash.yaml", "./bin/check") + mustWriteFile(t, filepath.Join(home, ".claude", "skills"), "not-a-directory") + + resetModeLabel(t) + var syncErr error + output := captureStdout(t, func() { + syncErr = cmdSync([]string{"--all"}) + }) + if syncErr == nil { + t.Fatal("expected cmdSync(--all) to report partial failure") + } + + mustExist(t, filepath.Join(home, ".claude", "rules", "manual.md")) + mustExist(t, filepath.Join(home, ".claude", "settings.json")) + if !strings.Contains(output, "rules: 1 updated") { + t.Fatalf("combined sync output missing rules detail after skills failure:\n%s", output) + } + if !strings.Contains(output, "hooks: 1 updated") { + t.Fatalf("combined sync output missing hooks detail after skills failure:\n%s", output) + } +} + +func TestCmdSync_ManagedOnlyCreatesBackupBeforeMutating(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + sourceDir := filepath.Join(t.TempDir(), "source") + mustAddSkill(t, sourceDir, "alpha") + + cfg := &config.Config{ + Source: sourceDir, + Targets: map[string]config.TargetConfig{ + "claude": {Path: filepath.Join(home, ".claude", "skills")}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + mustAddSkill(t, filepath.Join(home, ".claude", "skills"), "local") + putManagedRule(t, "", "claude/manual.md", "# Managed rule\n") + putManagedHook(t, "", "claude/pre-tool-use/bash.yaml", "./bin/check") + + resetModeLabel(t) + if err := cmdSync([]string{"--resources", "rules,hooks"}); err != nil { + t.Fatalf("cmdSync(--resources rules,hooks) error = %v", err) + } + + if _, err := os.Stat(backup.BackupDir()); err != nil { + t.Fatalf("expected sync backup directory to exist: %v", err) + } +} + +func TestSyncBackupPathsForTarget_RebasesToSafeToolRoot(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + sourceDir := filepath.Join(t.TempDir(), "source") + mustAddSkill(t, sourceDir, "alpha") + + targetPath := filepath.Join(home, ".claude", "skills") + cfg := &config.Config{ + Source: sourceDir, + Targets: map[string]config.TargetConfig{ + "claude": {Path: targetPath}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + mustAddSkill(t, targetPath, "local") + mustWriteFile(t, filepath.Join(targetPath, ".skillshare-manifest.json"), `{"managed":{"local":"abc123"}}`) + mustWriteFile(t, filepath.Join(home, ".claude", "rules", "manual.md"), "# Old managed rule\n") + mustWriteFile(t, filepath.Join(home, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[]}}`) + + putManagedRule(t, "", "claude/manual.md", "# New managed rule\n") + putManagedHook(t, "", "claude/pre-tool-use/bash.yaml", "./bin/check") + + paths, errs := syncBackupPathsForTarget(syncTargetEntry{ + name: "claude", + target: config.TargetConfig{Path: targetPath}, + }, resourceSelection{skills: true, rules: true, hooks: true}) + if len(errs) != 0 { + t.Fatalf("syncBackupPathsForTarget errors = %v", errs) + } + if len(paths) == 0 { + t.Fatal("expected snapshot paths") + } + + got := make([]string, 0, len(paths)) + for _, path := range paths { + got = append(got, path.RelativePath) + if strings.Contains(path.RelativePath, "..") { + t.Fatalf("relative path %q should not escape backup base", path.RelativePath) + } + } + + if !containsString(got, "skills") { + t.Fatalf("snapshot paths %v missing skills entry", got) + } + if !containsString(got, "rules") { + t.Fatalf("snapshot paths %v missing rules entry", got) + } + if !containsString(got, "settings.json") { + t.Fatalf("snapshot paths %v missing settings entry", got) + } +} + +func TestSyncBackupPathsForTarget_UniversalHooksUseSharedHomeAncestor(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + targetPath := filepath.Join(home, ".agents", "skills") + + mustWriteFile(t, filepath.Join(home, ".codex", "config.toml"), "[features]\ncodex_hooks = true\n") + mustWriteFile(t, filepath.Join(home, ".codex", "hooks.json"), `{"hooks":{"PreToolUse":[]}}`) + putManagedHookForTool(t, "", "codex/pre-tool-use/bash.yaml", "codex", "PreToolUse", "Bash", "./bin/check") + + paths, errs := syncBackupPathsForTarget(syncTargetEntry{ + name: "universal", + target: config.TargetConfig{Path: targetPath}, + }, resourceSelection{hooks: true}) + if len(errs) != 0 { + t.Fatalf("syncBackupPathsForTarget errors = %v", errs) + } + if len(paths) == 0 { + t.Fatal("expected snapshot paths") + } + + got := make([]string, 0, len(paths)) + for _, path := range paths { + got = append(got, path.RelativePath) + if strings.Contains(path.RelativePath, "..") { + t.Fatalf("relative path %q should stay within the shared ancestor", path.RelativePath) + } + } + + if !containsString(got, filepath.Join(".codex", "config.toml")) { + t.Fatalf("snapshot paths %v missing codex config", got) + } + if !containsString(got, filepath.Join(".codex", "hooks.json")) { + t.Fatalf("snapshot paths %v missing codex hooks", got) + } +} + +func TestCmdSync_ManagedOnlyBackupRestoreRoundTripRestoresManagedFiles(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + sourceDir := filepath.Join(t.TempDir(), "source") + mustAddSkill(t, sourceDir, "alpha") + + targetPath := filepath.Join(home, ".claude", "skills") + cfg := &config.Config{ + Source: sourceDir, + Targets: map[string]config.TargetConfig{ + "claude": {Path: targetPath}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + mustAddSkill(t, targetPath, "local") + originalRule := "# Old managed rule\n" + originalSettings := `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./bin/original"}]}]}}` + mustWriteFile(t, filepath.Join(home, ".claude", "rules", "manual.md"), originalRule) + mustWriteFile(t, filepath.Join(home, ".claude", "settings.json"), originalSettings) + + putManagedRule(t, "", "claude/manual.md", "# New managed rule\n") + putManagedHook(t, "", "claude/pre-tool-use/bash.yaml", "./bin/check") + + resetModeLabel(t) + if err := cmdSync([]string{"--resources", "rules,hooks"}); err != nil { + t.Fatalf("cmdSync(--resources rules,hooks) error = %v", err) + } + + backups, err := backup.FindBackupsForTarget("claude") + if err != nil { + t.Fatalf("FindBackupsForTarget(claude) error = %v", err) + } + if len(backups) == 0 { + t.Fatal("expected sync-created backup for claude") + } + + mustWriteFile(t, filepath.Join(home, ".claude", "rules", "manual.md"), "# Post-sync mutation\n") + mustWriteFile(t, filepath.Join(home, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./bin/mutated"}]}]}}`) + mustAddSkill(t, targetPath, "keep-me") + + if err := backup.RestoreToPath(backups[0].Path, "claude", targetPath, backup.RestoreOptions{Force: true}); err != nil { + t.Fatalf("RestoreToPath(managed backup) error = %v", err) + } + + assertFileContent(t, filepath.Join(home, ".claude", "rules", "manual.md"), originalRule) + assertFileContent(t, filepath.Join(home, ".claude", "settings.json"), originalSettings) + mustExist(t, filepath.Join(targetPath, "keep-me", "SKILL.md")) +} + +func TestCmdSync_ManagedOnlyBackupRestoreRoundTripRestoresAbsence(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + sourceDir := filepath.Join(t.TempDir(), "source") + mustAddSkill(t, sourceDir, "alpha") + + targetPath := filepath.Join(home, ".claude", "skills") + cfg := &config.Config{ + Source: sourceDir, + Targets: map[string]config.TargetConfig{ + "claude": {Path: targetPath}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + putManagedRule(t, "", "claude/manual.md", "# Managed rule\n") + putManagedHook(t, "", "claude/pre-tool-use/bash.yaml", "./bin/check") + + resetModeLabel(t) + if err := cmdSync([]string{"--resources", "rules,hooks"}); err != nil { + t.Fatalf("cmdSync(--resources rules,hooks) error = %v", err) + } + + backups, err := backup.FindBackupsForTarget("claude") + if err != nil { + t.Fatalf("FindBackupsForTarget(claude) error = %v", err) + } + if len(backups) == 0 { + t.Fatal("expected sync-created backup for claude") + } + + mustExist(t, filepath.Join(home, ".claude", "rules", "manual.md")) + mustExist(t, filepath.Join(home, ".claude", "settings.json")) + + if err := backup.RestoreToPath(backups[0].Path, "claude", targetPath, backup.RestoreOptions{Force: true}); err != nil { + t.Fatalf("RestoreToPath(absence backup) error = %v", err) + } + + mustNotExist(t, filepath.Join(home, ".claude", "rules", "manual.md")) + mustNotExist(t, filepath.Join(home, ".claude", "settings.json")) +} + +func TestCmdSync_AllAttemptsManagedResourcesWhenGlobalSourceMissing(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + cfg := &config.Config{ + Source: filepath.Join(t.TempDir(), "missing-source"), + Targets: map[string]config.TargetConfig{ + "claude": {Path: filepath.Join(home, ".claude", "skills")}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + putManagedRule(t, "", "claude/manual.md", "# Managed rule\n") + putManagedHook(t, "", "claude/pre-tool-use/bash.yaml", "./bin/check") + + resetModeLabel(t) + var syncErr error + output := captureStdout(t, func() { + syncErr = cmdSync([]string{"--all"}) + }) + if syncErr == nil { + t.Fatal("expected cmdSync(--all) to report partial failure") + } + + mustExist(t, filepath.Join(home, ".claude", "rules", "manual.md")) + mustExist(t, filepath.Join(home, ".claude", "settings.json")) + if !strings.Contains(output, "source directory does not exist") { + t.Fatalf("combined sync output missing source failure:\n%s", output) + } + if !strings.Contains(output, "rules: 1 updated") { + t.Fatalf("combined sync output missing rules detail after source failure:\n%s", output) + } + if !strings.Contains(output, "hooks: 1 updated") { + t.Fatalf("combined sync output missing hooks detail after source failure:\n%s", output) + } +} + +func TestCmdSync_AllCreatesBackupAfterSkillsSourceFailure(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + cfg := &config.Config{ + Source: filepath.Join(t.TempDir(), "missing-source"), + Targets: map[string]config.TargetConfig{ + "claude": {Path: filepath.Join(home, ".claude", "skills")}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + mustAddSkill(t, filepath.Join(home, ".claude", "skills"), "local") + putManagedRule(t, "", "claude/manual.md", "# Managed rule\n") + putManagedHook(t, "", "claude/pre-tool-use/bash.yaml", "./bin/check") + + resetModeLabel(t) + var syncErr error + output := captureStdout(t, func() { + syncErr = cmdSync([]string{"--all"}) + }) + if syncErr == nil { + t.Fatal("expected cmdSync(--all) to report partial failure") + } + + if _, err := os.Stat(backup.BackupDir()); err != nil { + t.Fatalf("expected sync backup directory to exist after source failure: %v", err) + } + if !strings.Contains(output, "source directory does not exist") { + t.Fatalf("combined sync output missing source failure:\n%s", output) + } +} + +func TestCmdSyncProject_ResourcesOnlyTargetsCanonicalRepoFiles(t *testing.T) { + projectRoot := t.TempDir() + setupProjectResourceTestEnv(t, projectRoot) + mustChdir(t, projectRoot) + + mustWriteProjectConfig(t, projectRoot) + mustAddSkill(t, filepath.Join(projectRoot, ".skillshare", "skills"), "alpha") + + putManagedRule(t, projectRoot, "claude/manual.md", "# Managed rule\n") + putManagedHook(t, projectRoot, "claude/pre-tool-use/bash.yaml", "./bin/check") + + resetModeLabel(t) + if err := cmdSync([]string{"-p", "--resources", "rules,hooks"}); err != nil { + t.Fatalf("cmdSync(-p --resources rules,hooks) error = %v", err) + } + + mustExist(t, filepath.Join(projectRoot, ".claude", "rules", "manual.md")) + mustExist(t, filepath.Join(projectRoot, ".claude", "settings.json")) + mustNotExist(t, filepath.Join(projectRoot, ".claude", "skills", "alpha")) +} + +func TestCmdSyncProject_AllAttemptsManagedResourcesWhenSourceMissing(t *testing.T) { + projectRoot := t.TempDir() + setupProjectResourceTestEnv(t, projectRoot) + mustChdir(t, projectRoot) + + mustWriteProjectConfig(t, projectRoot) + if err := os.RemoveAll(filepath.Join(projectRoot, ".skillshare", "skills")); err != nil { + t.Fatalf("remove project source: %v", err) + } + + putManagedRule(t, projectRoot, "claude/manual.md", "# Managed rule\n") + putManagedHook(t, projectRoot, "claude/pre-tool-use/bash.yaml", "./bin/check") + + resetModeLabel(t) + var syncErr error + output := captureStdout(t, func() { + syncErr = cmdSync([]string{"-p", "--all"}) + }) + if syncErr == nil { + t.Fatal("expected cmdSync(-p --all) to report partial failure") + } + + mustExist(t, filepath.Join(projectRoot, ".claude", "rules", "manual.md")) + mustExist(t, filepath.Join(projectRoot, ".claude", "settings.json")) + if !strings.Contains(output, "source directory does not exist") { + t.Fatalf("project sync output missing source failure:\n%s", output) + } + if !strings.Contains(output, "rules: 1 updated") { + t.Fatalf("project sync output missing rules detail after source failure:\n%s", output) + } + if !strings.Contains(output, "hooks: 1 updated") { + t.Fatalf("project sync output missing hooks detail after source failure:\n%s", output) + } +} + +func TestCmdCollectProject_ResourcesCollectIntoManagedStores(t *testing.T) { + projectRoot := t.TempDir() + home := filepath.Join(t.TempDir(), "home") + if err := os.MkdirAll(home, 0o755); err != nil { + t.Fatalf("mkdir home: %v", err) + } + t.Setenv("HOME", home) + setupProjectResourceTestEnv(t, projectRoot) + mustChdir(t, projectRoot) + + mustWriteProjectConfig(t, projectRoot) + mustWriteFile(t, filepath.Join(projectRoot, ".claude", "rules", "backend.md"), "# Backend rule\n") + mustWriteFile(t, filepath.Join(projectRoot, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./bin/check"}]}]}}`) + + resetModeLabel(t) + if err := cmdCollect([]string{"-p", "--resources", "rules,hooks", "--force"}); err != nil { + t.Fatalf("cmdCollect(-p --resources rules,hooks --force) error = %v", err) + } + + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Get("claude/backend.md"); err != nil { + t.Fatalf("managed rule not collected: %v", err) + } + + hookStore := managedhooks.NewStore(projectRoot) + records, err := hookStore.List() + if err != nil { + t.Fatalf("list managed hooks: %v", err) + } + if len(records) != 1 { + t.Fatalf("managed hooks = %#v, want exactly one collected hook", records) + } + if records[0].Tool != "claude" || records[0].Event != "PreToolUse" || records[0].Matcher != "Bash" { + t.Fatalf("managed hook = %#v, want claude PreToolUse Bash", records[0]) + } + if len(records[0].Handlers) != 1 || records[0].Handlers[0].Command != "./bin/check" { + t.Fatalf("managed hook handlers = %#v, want collected command handler", records[0].Handlers) + } +} + +func TestCmdCollect_SkillAndRuleFailuresStillAttemptManagedHooks(t *testing.T) { + home := setupGlobalResourceTestEnv(t) + sourcePath := filepath.Join(t.TempDir(), "source-file") + mustWriteFile(t, sourcePath, "not-a-directory") + mustAddSkill(t, filepath.Join(home, ".claude", "skills"), "alpha") + + cfg := &config.Config{ + Source: sourcePath, + Targets: map[string]config.TargetConfig{ + "claude": {Path: filepath.Join(home, ".claude", "skills")}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + mustWriteFile(t, filepath.Join(home, ".claude", "rules", "backend.md"), "# Backend rule\n") + mustWriteFile(t, filepath.Join(home, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./bin/check"}]}]}}`) + mustWriteFile(t, config.ManagedRulesDir(""), "not-a-directory") + + resetModeLabel(t) + var collectErr error + output := captureStdout(t, func() { + collectErr = cmdCollect([]string{"--resources", "skills,rules,hooks", "--force"}) + }) + if collectErr == nil { + t.Fatal("expected cmdCollect(--resources skills,rules,hooks --force) to report partial failure") + } + + hookStore := managedhooks.NewStore("") + records, err := hookStore.List() + if err != nil { + t.Fatalf("list managed hooks: %v", err) + } + if len(records) != 1 { + t.Fatalf("managed hooks = %#v, want exactly one collected hook after partial failure", records) + } + if records[0].Tool != "claude" || records[0].Event != "PreToolUse" || records[0].Matcher != "Bash" { + t.Fatalf("managed hook = %#v, want claude PreToolUse Bash", records[0]) + } + if !strings.Contains(output, "collected into managed store") { + t.Fatalf("collect output missing managed hook success after partial failure:\n%s", output) + } +} + +func setupGlobalResourceTestEnv(t *testing.T) string { + t.Helper() + root := t.TempDir() + home := filepath.Join(root, "home") + configHome := filepath.Join(root, "xdg-config") + dataHome := filepath.Join(root, "xdg-data") + stateHome := filepath.Join(root, "xdg-state") + cacheHome := filepath.Join(root, "xdg-cache") + for _, dir := range []string{home, configHome, dataHome, stateHome, cacheHome} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_STATE_HOME", stateHome) + t.Setenv("XDG_CACHE_HOME", cacheHome) + t.Setenv("SKILLSHARE_CONFIG", filepath.Join(configHome, "skillshare", "config.yaml")) + return home +} + +func setupProjectResourceTestEnv(t *testing.T, projectRoot string) { + t.Helper() + root := t.TempDir() + home := filepath.Join(root, "home") + configHome := filepath.Join(root, "xdg-config") + dataHome := filepath.Join(root, "xdg-data") + stateHome := filepath.Join(root, "xdg-state") + cacheHome := filepath.Join(root, "xdg-cache") + for _, dir := range []string{home, configHome, dataHome, stateHome, cacheHome} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_STATE_HOME", stateHome) + t.Setenv("XDG_CACHE_HOME", cacheHome) + t.Setenv("SKILLSHARE_CONFIG", filepath.Join(configHome, "skillshare", "config.yaml")) + if err := os.MkdirAll(projectRoot, 0o755); err != nil { + t.Fatalf("mkdir project root: %v", err) + } +} + +func mustWriteProjectConfig(t *testing.T, projectRoot string) { + t.Helper() + cfg := &config.ProjectConfig{ + Targets: []config.ProjectTargetEntry{{Name: "claude"}}, + } + if err := cfg.Save(projectRoot); err != nil { + t.Fatalf("save project config: %v", err) + } + if err := (&config.Registry{}).Save(filepath.Join(projectRoot, ".skillshare")); err != nil { + t.Fatalf("save project registry: %v", err) + } + if err := os.MkdirAll(filepath.Join(projectRoot, ".skillshare", "skills"), 0o755); err != nil { + t.Fatalf("mkdir project source: %v", err) + } +} + +func mustAddSkill(t *testing.T, sourceDir, name string) { + t.Helper() + mustWriteFile(t, filepath.Join(sourceDir, name, "SKILL.md"), "# "+name+"\n") +} + +func mustWriteFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func putManagedRule(t *testing.T, projectRoot, id, content string) { + t.Helper() + store := managedrules.NewStore(projectRoot) + if _, err := store.Put(managedrules.Save{ID: id, Content: []byte(content)}); err != nil { + t.Fatalf("put managed rule %s: %v", id, err) + } +} + +func putManagedHook(t *testing.T, projectRoot, id, command string) { + t.Helper() + putManagedHookForTool(t, projectRoot, id, "claude", "PreToolUse", "Bash", command) +} + +func putManagedHookForTool(t *testing.T, projectRoot, id, tool, event, matcher, command string) { + t.Helper() + store := managedhooks.NewStore(projectRoot) + if _, err := store.Put(managedhooks.Save{ + ID: id, + Tool: tool, + Event: event, + Matcher: matcher, + Handlers: []managedhooks.Handler{{ + Type: "command", + Command: command, + }}, + }); err != nil { + t.Fatalf("put managed hook %s: %v", id, err) + } +} + +func mustExist(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected %s to exist: %v", path, err) + } +} + +func mustNotExist(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s to not exist, err=%v", path, err) + } +} + +func assertFileContent(t *testing.T, path, want string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if string(data) != want { + t.Fatalf("%s content = %q, want %q", path, string(data), want) + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func mustChdir(t *testing.T, dir string) { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir %s: %v", dir, err) + } + t.Cleanup(func() { + _ = os.Chdir(wd) + }) +} + +func resetModeLabel(t *testing.T) { + t.Helper() + ui.ModeLabel = "" + t.Cleanup(func() { + ui.ModeLabel = "" + }) +} diff --git a/cmd/skillshare/sync_validation.go b/cmd/skillshare/sync_validation.go new file mode 100644 index 00000000..339fd6f3 --- /dev/null +++ b/cmd/skillshare/sync_validation.go @@ -0,0 +1,85 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + + "skillshare/internal/config" +) + +// validateConfigForSync keeps sync strict about config semantics while allowing +// resource-aware partial execution. Skills-only syncs should still fail fast on +// unusable source/target paths; managed-resource syncs can continue and report +// partial failures later in the workflow. +func validateConfigForSync(cfg *config.Config, resources resourceSelection) ([]string, error) { + var errs []string + + requireSourcePath := resources.skills && !resources.includesManaged() + requireTargetPathAccess := resources.skills && !resources.includesManaged() + + if cfg.Source == "" { + errs = append(errs, "source path is empty") + } else if requireSourcePath { + expanded := config.ExpandPath(cfg.Source) + info, statErr := os.Stat(expanded) + if statErr != nil { + if os.IsNotExist(statErr) { + errs = append(errs, fmt.Sprintf("source path does not exist: %s", cfg.Source)) + } else { + errs = append(errs, fmt.Sprintf("cannot access source path: %v", statErr)) + } + } else if !info.IsDir() { + errs = append(errs, fmt.Sprintf("source path is not a directory: %s", cfg.Source)) + } + } + + if !config.IsValidSyncMode(cfg.Mode) { + errs = append(errs, fmt.Sprintf("invalid global sync mode %q (valid: %s)", cfg.Mode, strings.Join(config.ValidSyncModes, ", "))) + } + if !config.IsValidTargetNaming(cfg.TargetNaming) { + errs = append(errs, fmt.Sprintf("invalid global target naming %q (valid: %s)", cfg.TargetNaming, strings.Join(config.ValidTargetNamings, ", "))) + } + + for name, target := range cfg.Targets { + sc := target.SkillsConfig() + if !config.IsValidSyncMode(sc.Mode) { + errs = append(errs, fmt.Sprintf("target %q: invalid sync mode %q (valid: %s)", name, sc.Mode, strings.Join(config.ValidSyncModes, ", "))) + continue + } + if !config.IsValidTargetNaming(sc.TargetNaming) { + errs = append(errs, fmt.Sprintf("target %q: invalid target naming %q (valid: %s)", name, sc.TargetNaming, strings.Join(config.ValidTargetNamings, ", "))) + continue + } + + if sc.Path == "" { + if _, known := config.LookupGlobalTarget(name); !known { + errs = append(errs, fmt.Sprintf("target %q: missing path (custom targets require skills.path)", name)) + } + continue + } + + if !requireTargetPathAccess { + continue + } + + expanded := config.ExpandPath(sc.Path) + info, statErr := os.Stat(expanded) + if statErr != nil { + if os.IsNotExist(statErr) { + continue + } + errs = append(errs, fmt.Sprintf("target %q: cannot access path: %v", name, statErr)) + continue + } + if !info.IsDir() { + errs = append(errs, fmt.Sprintf("target %q: path is not a directory: %s", name, expanded)) + } + } + + if len(errs) > 0 { + return nil, errors.New(strings.Join(errs, "; ")) + } + return nil, nil +} diff --git a/docs/superpowers/specs/2026-04-11-hooks-rules-managed-resources-design.md b/docs/superpowers/specs/2026-04-11-hooks-rules-managed-resources-design.md new file mode 100644 index 00000000..015d99dd --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-hooks-rules-managed-resources-design.md @@ -0,0 +1,239 @@ +# Hooks And Rules Managed Resources Design + +## Goal + +Make `Hooks` and `Rules` in `/resources` behave like `Skills` and `Agents` in `v0.19.0`: + +- `/resources` is a managed inventory surface +- tabs share the same top-level interaction model +- resources support real per-item target assignment +- discovery/import is not mixed into the managed inventory UI + +## Decision + +`/resources` will show only managed hooks and managed rules. + +Discovered hooks and discovered rules remain part of discovery and collect flows. They do not appear as primary inventory rows inside `/resources`. + +This matches the maintainer's resource model more closely: + +- managed inventory lives in `/resources` +- unmanaged/discovered state is an input to import/collect +- synced target files are compiled output, not canonical source-of-truth state + +## Why + +The current hooks/rules implementation mixes two concepts in one page: + +1. managed resources that Skillshare owns +2. discovered diagnostics from existing target files + +Skills and agents do not mix those concepts in `/resources`. Their UI feels coherent because every row is the same kind of thing: a managed resource with local state and actions. + +If hooks/rules keep discovered rows in the same inventory page, parity is only visual. The page would look like skills/agents while behaving like a diagnostics browser. + +## Product Shape + +### `/resources` + +Tabs: + +- `Skills` +- `Agents` +- `Hooks` +- `Rules` + +All four tabs share: + +- search +- sort +- view toggle +- source filter row +- right-click context menu pattern + +Hooks and rules will not show a `Managed / Discovered` toggle. + +### Discovery / import + +Discovered hooks and rules move out of `/resources`. + +They should appear where the user is deciding what to import into managed storage: + +- collect/import pages +- collect previews +- possible detail/import surfaces tied to discovery workflows + +The user experience becomes: + +1. discover existing hooks/rules from target config files +2. collect selected items into managed storage +3. manage those collected items in `/resources` + +## Data Model + +### Managed rules + +Managed rule records gain metadata required for parity with skills: + +- `targets?: string[]` +- `sourceType?: "local" | "github" | "tracked"` +- tracked repo provenance when applicable + +Semantics: + +- missing / empty / `["*"]` means all compatible targets +- explicit list means only those targets receive compiled output +- `sourceType` backs the `All / Tracked / GitHub / Local` row in `/resources` + +### Managed hooks + +Managed hook records gain the same optional target metadata: + +- `targets?: string[]` +- `sourceType?: "local" | "github" | "tracked"` +- tracked repo provenance when applicable + +Semantics match managed rules. + +### Compatibility + +Existing managed rules/hooks without targets continue to behave as global resources synced to all compatible targets. + +No existing managed content should require migration before it can load. + +## Sync And Compile Behavior + +### Rules + +Managed rule sync must only compile a rule for a target when: + +- the rule is compatible with that target family +- the target is included by the rule's `targets` metadata + +### Hooks + +Managed hook sync must only compile a hook for a target when: + +- the hook is compatible with that target family +- the target is included by the hook's `targets` metadata + +### Preview behavior + +Managed rule/hook previews should reflect the same target filtering logic used by sync. + +The UI must not offer a target assignment that preview/sync later ignores. + +## UI Design + +### Resources shell + +Hooks and rules should mirror skills/agents, not approximate them. + +That means: + +- no separate `Managed / Discovered` row +- no second `All / codex` filter row +- a single source filter row directly under search, using the same visual position as skills/agents +- view toggle available in the same header row + +### Filters + +Because `/resources` becomes managed-inventory-only for hooks/rules, source filters should match the same conceptual categories used elsewhere. + +For hooks/rules, these filters must be backed by persisted managed-record metadata, not fake counts. + +Collect/create/import flows must assign provenance metadata when managed records are created: + +- rules/hooks created directly in the UI default to `local` +- rules/hooks collected from tracked repos become `tracked` +- rules/hooks collected from non-tracked GitHub installs become `github` + +### Right-click menu parity + +Managed hooks and rules should support the same menu shape as skills: + +- `Available in...` +- `View Detail` +- `Disable` / `Enable` if supported +- `Uninstall` + +If hooks/rules do not currently support `disabled`, the menu should either: + +- add real disabled state, or +- omit only that item while preserving the rest of the pattern + +`Available in...` must be real target assignment backed by managed record metadata. + +### Detail behavior + +Token display should match upstream `v0.19.0` behavior: + +- token stats belong on the resource detail page +- file viewer modal should not gain custom token UI beyond what upstream does + +## Architecture Constraints + +This work should follow `v0.19.0` architecture decisions instead of introducing a second resource model. + +That means: + +- preserve `/rules` and `/hooks` as compatibility aliases if needed +- keep `/resources` as the shared managed shell +- do not keep a special-case hooks/rules toolbar model +- do not encode discovery state as if it were managed inventory state + +## Implementation Outline + +1. Extend managed rule and managed hook record schemas to store `targets` +2. Load and save that metadata through CLI and server APIs +3. Update compile/sync/preview logic to honor per-item targets +4. Add target assignment UI and right-click menu parity for managed hooks/rules +5. Remove `Managed / Discovered` and discovery-only filter rows from `/resources` +6. Restrict `/resources` hooks/rules tabs to managed inventory only +7. Move discovered hooks/rules access to collect/import-oriented flows +8. Restore `ResourceDetailPage` token behavior to match `v0.19.0` + +## Non-Goals + +- redesigning the maintainer's `v0.19.0` resources architecture +- keeping discovered hooks/rules as first-class inventory rows inside `/resources` +- inventing fake source-filter counts for hooks/rules +- adding markdown editing features from the separate branch + +## Risks + +### Record format drift + +Adding targets to managed records changes persisted schema. This must remain backward-compatible and be covered by round-trip tests. + +### Sync regressions + +Per-item target filtering affects compile output. Rules/hooks may silently stop syncing if filtering logic is wrong. Preview and sync must share the same targeting rules. + +### UI parity drift + +If `/resources` retains discovery-specific controls for hooks/rules, the surface will remain inconsistent and harder to maintain. + +## Testing + +### Backend + +- managed rule store round-trip with and without `targets` +- managed hook store round-trip with and without `targets` +- compile/sync respects per-item targets +- preview respects per-item targets +- collect/create paths preserve provenance and targets defaults + +### UI + +- `/resources` tab order and chrome parity +- hooks/rules tabs show no `Managed / Discovered` toggle +- hooks/rules right-click menu exposes real `Available in...` +- target assignment updates the managed record and affects preview/sync state +- token display matches upstream detail-page behavior + +## Recommendation + +Proceed with managed-only hooks/rules in `/resources` and add true per-item target assignment. + +This is the cleanest way to make hooks and rules behave like skills and agents without diverging from the maintainer's resource model. diff --git a/docs/superpowers/specs/2026-04-11-native-rules-hooks-families-design.md b/docs/superpowers/specs/2026-04-11-native-rules-hooks-families-design.md new file mode 100644 index 00000000..1cb31605 --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-native-rules-hooks-families-design.md @@ -0,0 +1,947 @@ +# Native Rules And Hooks Families Design + +Date: 2026-04-11 +Status: Draft for review + +## Summary + +This design extends the earlier `v0.19.0` managed-resources alignment work with +an explicit native capability model for `rules` and `hooks`. + +The key decision is: + +- keep Skillshare's broad built-in target registry for `skills` +- keep narrower per-resource capability subsets for richer resource families +- model managed `rules` and `hooks` the same way `agents` are already modeled + +In practice, that means: + +- `supported targets` remains the source of truth for target names, aliases, and + skill paths +- managed `rules` and managed `hooks` gain their own explicit capability matrix +- CLI, server, and UI read from the same family definitions instead of + hardcoding partial support in multiple places +- the initial family split is explicit: + - managed `rules`: `claude`, `codex`, `gemini`, `pi` + - managed `hooks`: `claude`, `codex`, `gemini` + +This follows the maintainer's existing architecture rather than introducing a +new one. + +## Relationship To Existing Specs + +This document builds on: + +- [v0.19.0-Aligned Managed Resources Refactor](./2026-04-11-v019-managed-resources-alignment-design.md) +- [Hooks And Rules Managed Resources Design](./2026-04-11-hooks-rules-managed-resources-design.md) + +Those documents establish: + +- `/resources` as the primary managed inventory surface +- managed-only `rules` and `hooks` inventory in `/resources` +- shared managed-resource orchestration for sync and collect + +This document adds the missing piece: + +- how native `rules` and `hooks` capability should be represented across the + many targets Skillshare knows about +- how CLI authoring and editing should work without pretending every skill + target supports every managed resource family + +## Problem + +The current branch has three forms of drift: + +### 1. Supported-target drift + +Skillshare's docs and `targets.yaml` define a broad registry of built-in +targets. That registry primarily answers: + +- what target names exist +- what aliases resolve to them +- where their skill directories live + +It does **not** mean every target supports: + +- `agents` +- native instruction files +- native hooks + +The codebase already recognizes this distinction for `agents`, but `rules` and +`hooks` do not yet have a comparable explicit capability model. + +### 2. Implicit family drift + +Managed `rules` and `hooks` currently infer target support from a mix of: + +- target name matching +- path heuristics +- branch-local assumptions about shared directories such as `.agents` + +That works for the currently implemented cases, but it is not explicit enough to +scale cleanly to Gemini, Pi, or other target families. It also makes UI and CLI +validation harder to keep in sync. + +### 3. CLI parity drift + +Managed `rules` and `hooks` already have server CRUD APIs and UI flows, but the +CLI still exposes them mostly through: + +- `skillshare sync --resources rules,hooks` +- `skillshare collect --resources rules,hooks` + +There is no first-class managed-resource authoring surface in the CLI that +matches the fact that these resources are now first-class in the UI. + +## Maintainer Precedent + +The strongest evidence for the correct direction is the existing `agents` +architecture. + +The codebase already separates: + +- a broad built-in target registry for `skills` +- a narrower capability subset for `agents` + +In [internal/config/targets.go](../../../internal/config/targets.go), target +specs have required `Skills` paths and optional `Agents` paths. + +That produces two different registries: + +- `DefaultTargets` / `ProjectTargets` for all skill-capable targets +- `DefaultAgentTargets` / `ProjectAgentTargets` for the narrower + agent-capable subset + +`syncAgentsGlobal` explicitly syncs only to `agent-capable targets` in +[cmd/skillshare/sync_agents.go](../../../cmd/skillshare/sync_agents.go). + +Tests also codify that some built-in targets should be excluded from the agent +subset even though they are supported skill targets: + +- `copilot` +- `codex` +- `windsurf` + +See [internal/config/targets_test.go](../../../internal/config/targets_test.go). + +This is the architectural precedent this design follows: + +- broad registry for target identity +- narrower capability subset per richer resource family + +## Decision + +Adopt **native capability families** for managed `rules` and `hooks`. + +This is "option 2" from design discussion: + +- keep the supported-targets registry broad +- introduce an explicit managed capability registry for `rules` and `hooks` +- map concrete target names into native families only when they actually share + the same file formats and runtime semantics + +This design does **not** attempt to give every supported skill target generic +rules/hooks support. + +## Goals + +- Preserve the maintainer's existing `skills` vs `agents` architectural split. +- Add managed `rules` and `hooks` using the same pattern. +- Make native capability support explicit, testable, and shared across CLI, + server, and UI. +- Add first-class CLI authoring flows for managed `rules` and `hooks`. +- Drive hook/rule editor options from native family definitions instead of + frontend-only hardcoded assumptions. +- Preserve existing compatibility behavior already codified in this branch, + including current `.agents` / `universal` Codex-family compatibility. + +## Non-Goals + +- No attempt to make all 56+ skill targets support native `rules` or `hooks`. +- No fake cross-tool hook abstraction that erases native semantics. +- No replacement of Pi extensions with a generic hook layer. +- No broad redesign of the `skills` or `agents` command model. +- No removal of existing `sync --resources` or `collect --resources` flows. + +## Design Overview + +Skillshare should model four different layers: + +1. `target registry` +2. `resource family registry` +3. `target -> family resolution` +4. `family-specific adapters and validators` + +### 1. Target Registry + +The current target registry remains the source of truth for: + +- canonical target names +- aliases +- skill paths +- optional agent paths + +This remains the responsibility of `targets.yaml` plus the existing config +helpers. + +### 2. Managed Capability Registry + +Introduce a new explicit registry for native managed-resource support. + +This registry answers: + +- which families support managed `rules` +- which families support managed `hooks` +- which target names resolve to those families +- which files or config surfaces each family owns +- which events, handler types, and fields are valid for each family + +The capability registry should live in managed-resource code, not in frontend +components and not in ad hoc path heuristics. + +A plausible home is: + +- `internal/resources/managed/capabilities.go` + +with supporting family-specific files if needed. + +### 3. Target-To-Family Resolution + +All rule/hook preview, sync, diff, collect, UI forms, and CLI authoring should +resolve through the same functions. + +Resolution must prefer: + +1. explicit target-name mapping +2. alias-aware matching +3. validated compatibility fallbacks + +It should not rely on loose path guessing as the primary contract. + +### 4. Family-Specific Adapters + +Each native family continues to own its own: + +- compile logic +- collect/import logic +- validation rules +- preview root behavior + +The new registry coordinates them. It does not flatten them into a generic +format. + +## Capability Matrix + +### Skills + +`skills` remain broad and continue to use the existing supported-targets +registry. + +### Agents + +`agents` remain the current narrower subset defined by optional agent paths. + +### Rules + +Initial native managed `rules` families: + +- `claude` +- `codex` +- `gemini` +- `pi` + +### Hooks + +Initial native managed `hooks` families: + +- `claude` +- `codex` +- `gemini` + +`pi` does **not** join the managed hooks family set. Pi's native extension +system is closer to a programmable runtime/plugin surface than a +`settings.json` hook registry and should not be misrepresented as equivalent. + +## Family Definitions + +### Claude Rules Family + +Primary managed surfaces: + +- project root `CLAUDE.md` +- `./.claude/rules/**` +- global `~/.claude/CLAUDE.md` +- global `~/.claude/rules/**` + +Managed rule semantics remain the current Claude semantics. + +### Codex Rules Family + +Primary managed surfaces: + +- project root `AGENTS.md` +- global `~/.codex/AGENTS.md` + +Compatibility behavior to preserve: + +- the current branch treats the shared `.agents` / `universal` target as a + Codex-family managed rule destination +- existing tests already codify this behavior + +That compatibility remains supported, but as an explicit mapping rule rather +than an accidental side effect of path guessing. + +### Gemini Rules Family + +Primary managed surfaces: + +- project root `GEMINI.md` +- `./.gemini/rules/**` +- global `~/.gemini/GEMINI.md` +- global `~/.gemini/rules/**` + +This aligns with Gemini CLI's documented `GEMINI.md` context model. + +### Pi Rules Family + +Pi's native instruction surfaces are not a `rules/` directory. They are: + +- `AGENTS.md` +- nested `AGENTS.md` files in the project tree +- `.pi/SYSTEM.md` +- `.pi/APPEND_SYSTEM.md` +- global equivalents under `~/.pi/agent/` + +Therefore the Pi rules family should be modeled as **managed instruction files** +rather than "markdown fragments under a rules directory". + +Initial managed Pi rule support should cover the documented instruction +surfaces: + +- `pi/AGENTS.md` +- `pi/**/AGENTS.md` when explicitly targeted +- `pi/SYSTEM.md` +- `pi/APPEND_SYSTEM.md` + +This family requires its own compile adapter and path validator because its +native surfaces differ from Claude/Codex/Gemini. + +### Claude Hooks Family + +Native surface: + +- `settings.json` under Claude's native config root + +Supported handler types remain: + +- `command` +- `http` +- `prompt` +- `agent` + +Handler fields remain Claude-native: + +- command/url/prompt +- timeout string +- status message + +### Codex Hooks Family + +Native surfaces: + +- `.codex/config.toml` +- `.codex/hooks.json` + +Semantics to preserve: + +- feature flag in `config.toml` +- hook definitions in `hooks.json` +- supported events only: + - `SessionStart` + - `PreToolUse` + - `PostToolUse` + - `UserPromptSubmit` + - `Stop` +- `command` handlers only +- timeout must be numeric seconds +- `UserPromptSubmit` and `Stop` require empty matcher + +Compatibility behavior to preserve: + +- current branch maps `universal` / `.agents` shared targets into Codex-family + preview roots + +### Gemini Hooks Family + +Native surface: + +- `settings.json` under Gemini config + +Supported hook model, based on Gemini CLI docs: + +- `command` hooks only +- event groups: + - `SessionStart` + - `SessionEnd` + - `BeforeAgent` + - `AfterAgent` + - `BeforeModel` + - `AfterModel` + - `BeforeToolSelection` + - `BeforeTool` + - `AfterTool` + - `PreCompress` + - `Notification` +- hook fields: + - `name` + - `description` + - `command` + - `timeout` in milliseconds +- group fields: + - `matcher` + - `sequential` + +Matcher semantics differ by event type: + +- tool events use regex matching +- lifecycle events use exact string matching +- empty or `*` means all + +This is materially different from Claude and Codex. It must be modeled +explicitly rather than stuffed into the current generic hook form. + +### Pi Hooks + +No managed Pi hooks family is introduced. + +Pi's native automation surface is extensions under: + +- `~/.pi/agent/extensions/` +- `.pi/extensions/` + +That is a separate product surface and should remain out of scope for managed +hooks. The spec should describe this clearly in docs and UI copy so users do not +assume missing support is accidental. + +## Target Mapping Rules + +The capability registry must distinguish: + +- `target identity` +- `native family` + +### Initial explicit mappings + +The initial implementation should preserve currently verified mappings: + +- `claude` +- `claude-code` -> `claude` +- `codex` +- `gemini` +- `gemini-cli` -> `gemini` +- `pi` + +The initial implementation should **not** map `omp` / `oh-my-pi` into the Pi +family until native rule and hook parity is explicitly verified. Shared skills +paths or branding similarity are not sufficient evidence. + +### Shared-target compatibility mappings + +The branch currently codifies `.agents` / `universal` compatibility as +Codex-family output for managed rules and hooks. This should remain supported. + +The design should therefore explicitly map: + +- `universal` -> Codex family for managed rules +- `universal` -> Codex family for managed hooks + +This is a compatibility rule, not a blanket statement that all `.agents`-style +tools are Codex-compatible. + +### Out-of-scope mappings + +Targets should **not** be mapped into a managed family solely because they share +a skills directory path. + +Examples: + +- `windsurf` +- `warp` +- `witsy` +- `replit` +- `purecode` +- `omp` +- `xcode-claude` +- `xcode-codex` + +If a target later proves it shares native managed semantics with an existing +family, that mapping can be added deliberately. + +## Exhaustive Target Coverage + +The spec should provide **100% classification coverage** for the current built-in +target registry in `internal/config/targets.yaml`. + +That means every canonical built-in target must be in exactly one of these +states: + +- mapped to a managed `rules` and `hooks` family +- mapped to a managed `rules` family only +- supported for `skills`, but not mapped to managed `rules` or `hooks` yet + +It does **not** mean every skill target gets native managed hooks/rules support. +That would misrepresent tools that do not expose comparable native surfaces. + +Aliases inherit the disposition of their canonical target unless the spec says +otherwise. + +This appendix is exhaustive for the current 56 canonical built-in targets and +must be updated whenever `targets.yaml` changes. + +### Managed Rules And Hooks + +Claude family: + +- `claude` + +Codex family: + +- `codex` +- `universal` + +Gemini family: + +- `gemini` + +### Managed Rules Only + +Pi family: + +- `pi` + +### Skills-Only In Initial Pass + +- `adal` +- `amp` +- `antigravity` +- `astrbot` +- `augment` +- `bob` +- `cline` +- `codebuddy` +- `comate` +- `commandcode` +- `continue` +- `cortex` +- `copilot` +- `crush` +- `cursor` +- `deepagents` +- `droid` +- `firebender` +- `goose` +- `hermes` +- `iflow` +- `junie` +- `kilocode` +- `kimi` +- `kiro` +- `kode` +- `letta` +- `lingma` +- `mcpjam` +- `mux` +- `neovate` +- `omp` +- `openclaw` +- `opencode` +- `openhands` +- `pochi` +- `purecode` +- `qoder` +- `qwen` +- `roo` +- `trae` +- `trae-cn` +- `vibe` +- `verdent` +- `warp` +- `windsurf` +- `witsy` +- `xcode-claude` +- `xcode-codex` +- `zencoder` +- `replit` + +### Explicit Non-Inferences + +The following should remain skills-only until explicitly verified, even though +their names or shared paths could tempt over-mapping: + +- `.agents`-path targets other than `universal` and current Codex compatibility +- `omp` / `oh-my-pi` +- `xcode-claude` +- `xcode-codex` + +## Family Decision Rule + +Not every target needs its own hook or rule design. + +The right default is: + +- every target gets an explicit classification +- only verified native capability surfaces get mapped into managed families +- a new family is added only when an existing family cannot faithfully represent + the target's native files, semantics, and validation rules + +### Map To An Existing Family When + +- the target is an alias of an existing canonical target +- the target uses the same native files and directories +- the target uses the same event model and field schema +- the target uses the same compile and collect semantics + +Examples: + +- `claude-code` -> Claude family +- `gemini-cli` -> Gemini family +- `universal` -> Codex compatibility family for current managed rules/hooks + +### Add A New Family When + +- the target has a native rules surface, but it is structurally different from + existing families +- the target has a native hooks surface, but its event model or handler schema + is materially different from existing families +- mapping it into an existing family would force fake fields, invalid options, + or lossy compile behavior + +Example: + +- `pi` needs its own managed rules family because its instruction surfaces are + `AGENTS.md`, `.pi/SYSTEM.md`, and `.pi/APPEND_SYSTEM.md`, which do not match + Claude/Codex/Gemini rule layouts + +### Keep Skills-Only When + +- Skillshare knows the target's skills path, but native rules/hooks support has + not been verified +- the target exposes no comparable native hooks/rules surface +- the only evidence is a shared directory path or similar branding + +Examples: + +- `omp` +- `xcode-claude` +- `xcode-codex` +- most `.agents`-path targets + +## CLI Design + +### Principle + +CLI parity with skills does **not** mean copying the entire skills lifecycle. + +Skills are installable packages and directories. Managed `rules` and `hooks` +are source records with native-family compile semantics. + +So parity should mean: + +- first-class CLI visibility +- first-class CLI creation +- first-class CLI inspection +- first-class CLI mutation +- first-class CLI deletion + +not: + +- repo search/install/update workflows identical to skills + +### New command groups + +Add first-class managed-resource command groups: + +- `skillshare rules ...` +- `skillshare hooks ...` + +These commands sit alongside the existing generic: + +- `sync --resources ...` +- `collect --resources ...` + +### Rules commands + +Initial rules command surface: + +- `skillshare rules list` +- `skillshare rules show ` +- `skillshare rules new` +- `skillshare rules update ` +- `skillshare rules delete ` +- `skillshare rules enable ` +- `skillshare rules disable ` +- `skillshare rules target add ` +- `skillshare rules target remove ` +- `skillshare rules target clear ` +- `skillshare rules diff` + +Creation and update should support both: + +- simple structured flags for common flows +- `--stdin` or `--file` for raw content-oriented workflows + +### Hooks commands + +Initial hooks command surface: + +- `skillshare hooks list` +- `skillshare hooks show ` +- `skillshare hooks new` +- `skillshare hooks update ` +- `skillshare hooks delete ` +- `skillshare hooks enable ` +- `skillshare hooks disable ` +- `skillshare hooks target add ` +- `skillshare hooks target remove ` +- `skillshare hooks target clear ` +- `skillshare hooks diff` + +Hook create/update must be family-aware. That means: + +- Codex creation only offers Codex-supported events and `command` handlers. +- Gemini creation only offers Gemini-supported events and command-hook fields. +- Claude creation can offer the broader handler set. + +### Keep existing sync and collect flows + +Do **not** remove: + +- `skillshare sync --resources rules,hooks` +- `skillshare collect --resources rules,hooks` + +The new command groups are authoring and inspection surfaces, not replacements +for orchestration commands. + +## Server And API Design + +### Keep current CRUD endpoints + +The current managed CRUD endpoints remain the base contract: + +- `/api/managed/rules` +- `/api/managed/hooks` + +### Add capability metadata endpoint + +Add a read-only endpoint for UI and CLI metadata generation: + +- `/api/managed/capabilities` + +This endpoint should expose: + +- supported rule families +- supported hook families +- family-to-target mappings +- rule path templates / allowed surfaces +- hook event lists +- hook handler type lists +- field constraints per family +- compatibility notes such as `pi hooks unsupported` + +The goal is to eliminate duplicated frontend-only capability logic. + +## UI Design + +### Resources page stays managed-only + +This design does not revisit the earlier decision: + +- `/resources` remains managed inventory +- discovered import diagnostics remain outside primary managed inventory rows + +### Family-driven create flows + +`New Rule` and `New Hook` should become family-driven forms. + +The form should first resolve or select: + +- tool family + +and then render only supported fields for that family. + +### Hook editor behavior + +Current generic hook UI should be replaced by family-aware rendering sourced +from the backend capability registry. + +Examples: + +- Codex: no `http`, `prompt`, or `agent` handlers; no invalid events; matcher + disabled for `UserPromptSubmit` and `Stop` +- Gemini: command-only hooks, group-level `sequential`, hook `name`, + `description`, timeout in milliseconds +- Claude: richer handler palette + +### Rule editor behavior + +Rule creation should also be family-aware: + +- Claude/Codex/Gemini can continue to look mostly content-oriented +- Pi should offer explicit instruction surfaces such as `AGENTS.md`, + `SYSTEM.md`, and `APPEND_SYSTEM.md` + +### Unsupported-family messaging + +When a target is supported for skills but not for managed hooks/rules, the UI +should say so plainly. + +Example: + +- Pi can appear in rules target pickers +- Pi should not appear in hooks target pickers +- Windsurf may appear as a supported skills target but should not imply managed + rules/hooks support + +## Implementation Architecture + +### New capability layer + +Introduce a managed capability layer above the existing low-level adapters. + +Responsibilities: + +- enumerate managed rule families +- enumerate managed hook families +- resolve target names to managed families +- surface family metadata to CLI and server +- define supported create/update schemas + +Non-responsibilities: + +- compiling files directly +- replacing family-specific adapters +- handling HTTP rendering + +### Existing low-level packages remain + +Keep existing packages as the source of family-specific compile behavior: + +- `internal/resources/rules` +- `internal/resources/hooks` +- `internal/resources/adapters/*` + +The new capability layer coordinates them. + +### Current resolver replacement + +The current branch has family inference embedded in functions such as: + +- `resolveRuleTargetFamily` +- `resolveHookTargetFamily` +- `managedRulePathFamily` +- `managedHookPathFamily` + +Those should be rewritten to delegate to the capability registry so path-based +fallbacks become explicit compatibility rules rather than hidden behavior. + +## Migration And Compatibility + +### Persisted records + +Existing managed rule and hook records remain valid. + +This design does not require record migration to add family support. Family is +derived from: + +- record `tool` +- target mapping + +### Existing compatibility routes and resources UI + +No change to the earlier `/resources` design direction. + +### Universal compatibility + +Preserve current `.agents` / `universal` behavior for Codex-family managed +rules/hooks. + +If the project later decides to narrow that behavior, it must happen in a +separate compatibility-review change with explicit migration messaging and test +updates. + +## Documentation Changes + +The current `supported targets` docs should not be overloaded to imply managed +resource support. + +Add a separate docs surface such as: + +- `Reference -> Managed Capabilities` + +That page should distinguish: + +- supported `skills` targets +- agent-capable targets +- rule-capable families +- hook-capable families + +## Risks + +### Over-generalization risk + +If family definitions are too generic, the UI and CLI will again allow invalid +combinations that backend adapters later reject. + +### Compatibility risk + +If we tighten mapping too aggressively, we may break current `universal` +Codex-family behavior that branch tests already encode. + +### Pi modeling risk + +Pi's instruction and extension surfaces are different enough from +Claude/Codex/Gemini that forcing them into the same mental model will create UX +and code complexity. The design deliberately keeps Pi hooks out of managed hooks +to avoid that trap. + +## Testing + +### Backend + +- capability registry resolves canonical names and aliases correctly +- unsupported targets remain unsupported for rules/hooks even when they are + valid skill targets +- existing `universal` compatibility remains intact for Codex-family previews +- Gemini hooks compile, preview, and validate using Gemini-native event and + field constraints +- Pi rules validate and compile only for supported Pi instruction surfaces + +### CLI + +- `skillshare rules list/new/show/update/delete` works in both global and + project mode +- `skillshare hooks list/new/show/update/delete` works in both global and + project mode +- invalid family-specific flag combinations fail early with clear errors + +### UI + +- create and edit flows derive options from `/api/managed/capabilities` +- hook editor shows only valid fields for the selected family +- Pi does not appear as a hook-capable family +- unsupported targets are clearly labeled rather than silently omitted where + appropriate + +## Recommendation + +Proceed with native managed capability families. + +This is the closest fit to the maintainer's existing design: + +- broad target registry for `skills` +- narrower capability subsets for richer resource families +- explicit compatibility rules instead of hidden heuristics + +It preserves existing branch behavior where compatibility is already codified, +adds the missing CLI surface for managed `rules` and `hooks`, and gives both the +CLI and UI a single source of truth for what each family actually supports. diff --git a/docs/superpowers/specs/2026-04-11-v019-managed-resources-alignment-design.md b/docs/superpowers/specs/2026-04-11-v019-managed-resources-alignment-design.md new file mode 100644 index 00000000..afe86f7a --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-v019-managed-resources-alignment-design.md @@ -0,0 +1,483 @@ +# v0.19.0-Aligned Managed Resources Refactor + +Date: 2026-04-11 +Status: Approved for planning + +## Summary + +This design aligns the branch's managed `rules` and `hooks` work with the architectural direction established in Skillshare `v0.19.0`. + +The refactor has two goals: + +1. Preserve `v0.19.0`'s product and architecture decisions. +2. Remove the branch-local duplication introduced while porting managed `rules` and `hooks` onto a newer base. + +The result is not a full rewrite of Skillshare sync and collect. Instead, it is a targeted refactor that: + +- keeps `v0.19.0`'s existing skills and agents architecture intact +- keeps the existing separation between skills sync and agents sync intact +- makes `/resources` the primary management surface for `skills`, `agents`, `rules`, and `hooks` +- adds `rules` and `hooks` into that architecture as a dedicated managed-resource subsystem +- extracts shared managed-resource orchestration so CLI and server entrypoints stop re-implementing the same `rules` and `hooks` sync and collect flows + +## Problem + +After porting the managed `rules` and `hooks` work onto `v0.19.0`, the branch now has two kinds of drift: + +### UI drift + +`v0.19.0` introduced a consolidated `/resources` page for `skills` and `agents`, with a standardized header, search, filters, sort controls, and bulk interaction model. + +Managed `rules` and `hooks` were added as separate first-class pages and sidebar entries, each with their own duplicated page shell. This diverges from the new `v0.19.0` product shape and makes the UI feel older than the base version. + +### Orchestration drift + +Managed resource sync and collect logic is spread across multiple layers: + +- CLI sync code in `cmd/skillshare/sync.go` +- CLI managed-resource helpers in `cmd/skillshare/managed_resources.go` +- server sync code in `internal/server/handler_sync.go` +- server managed-resource helpers in `internal/server/managed_resource_sync.go` +- server collect handlers in `internal/server/handler_managed_rules.go` and `internal/server/handler_managed_hooks.go` + +The managed `rules` and `hooks` code paths perform very similar work: + +- resolve target-specific compile context +- load managed records +- scan discovered items when collecting +- compile managed output +- apply compiled files +- prune managed rule orphans where needed +- report created, overwritten, skipped, updated, and pruned results + +The logic is currently correct enough to use, but the same orchestration concepts are repeated in different shapes for CLI and server code. + +## Goals + +- Preserve `v0.19.0` architecture decisions rather than replacing them. +- Preserve the existing `v0.19.0` split between skills sync and agents sync. +- Make `/resources` the single management surface for `skills`, `agents`, `rules`, and `hooks`. +- Remove `Rules` and `Hooks` as primary sidebar destinations. +- Keep existing managed rule and hook editing pages working. +- Extract shared managed-resource sync and collect orchestration into internal reusable code. +- Keep CLI behavior and HTTP API behavior functionally stable unless a mismatch is clearly a bug. +- Reduce code duplication without forcing skills, agents, extras, and managed resources into one oversized generic engine. + +## Non-Goals + +- No rewrite of the core skills or agents sync architecture introduced in `v0.19.0`. +- No new generic sync or collect engine spanning skills, agents, rules, hooks, and extras. +- No attempt to unify every syncable thing in Skillshare into a single global planner. +- No new markdown editing experience for `SKILL.md` or other files in this branch. + That work exists on a separate branch and is explicitly out of scope here. +- No redesign of managed rule and hook detail editors beyond route and navigation integration. +- No broad renaming of public CLI flags or API payloads. + +## Design Principles + +### Follow `v0.19.0` first + +Where this branch and `v0.19.0` disagree, `v0.19.0` wins unless there is a concrete regression or missing capability required for managed `rules` and `hooks`. + +### Extract branch-added duplication, not product abstractions for their own sake + +The refactor should remove duplicated orchestration and duplicated page shells that were introduced by the managed resource port. It should not invent a new meta-framework that the base project does not need. + +### Keep entrypoints thin + +CLI commands and HTTP handlers should validate inputs, call shared orchestration code, and format outputs. They should not own compile/apply/scan/prune workflows directly. + +### Match existing subsystem boundaries + +`v0.19.0` already treats skills and agents as separate sync subsystems under a shared product surface. This refactor should follow that pattern. + +`rules` and `hooks` should be added the same way: + +- shared product entrypoint +- dedicated internal orchestration for their own behavior +- no forced merger with the skills or agents engines + +## Current-State Assessment + +### UI + +- `/resources` already provides the canonical `v0.19.0` shell for browsing `skills` and `agents`. +- `/rules` and `/hooks` duplicate the same shell structure with separate page-level implementations. +- The sidebar still exposes `Rules` and `Hooks` as primary navigation items, which conflicts with the desired consolidated `v0.19.0` direction. +- Managed rule and hook detail routes already exist and are useful. Those should stay. + +### Backend + +- CLI sync and server sync each run managed `rules` and `hooks` through similar but separate target loops. +- CLI collect and server collect each reconstruct managed collect behavior separately. +- `v0.19.0` already keeps skills sync and agents sync on separate orchestration paths. +- Managed resource compilation and application already rely on lower-level packages such as `internal/resources/rules`, `internal/resources/hooks`, and `internal/resources/apply`. Those lower-level packages should remain the foundation. + +The missing layer is a shared managed-resource orchestration package that sits above the low-level resource packages and below the CLI/server entrypoints. + +This layer should mirror the existing architectural pattern rather than replace it: + +- skills remain on the existing skills sync path +- agents remain on the existing agents sync path +- rules and hooks gain a dedicated managed-resource path + +## 1. UI Surface Alignment + +`/resources` becomes the canonical browsing surface for: + +- `skills` +- `agents` +- `rules` +- `hooks` + +The `ResourcesPage` shell remains the visual and interaction model established by `v0.19.0`. Managed `rules` and `hooks` are added as additional top-level tabs in that shell. + +Each tab owns its own data adapter for: + +- list query +- tab counts +- search matching +- filter options +- sort options +- row or card rendering +- primary actions such as `New Agent`, `New Rule`, or `New Hook` + +This keeps the page aligned with `v0.19.0` without forcing `skills`, `agents`, `rules`, and `hooks` into a single deeply generic renderer. + +### Route behavior + +Primary routes: + +- `/resources` with tab state in the URL via `tab=skills|agents|rules|hooks` +- `/resources/new?kind=agent` stays as-is +- managed rule and hook detail/edit routes stay as-is + +Compatibility routes: + +- `/rules` redirects to `/resources?tab=rules` +- `/hooks` redirects to `/resources?tab=hooks` + +Rules and hooks keep their existing secondary browsing mode inside the resources tab: + +- `mode=managed` +- `mode=discovered` + +Compatibility redirects should preserve this mode when present. For example: + +- `/rules?mode=discovered` redirects to `/resources?tab=rules&mode=discovered` +- `/hooks?mode=managed` redirects to `/resources?tab=hooks&mode=managed` + +Compatibility detail routes remain valid: + +- `/rules/new` +- `/rules/manage/*` +- `/rules/discovered/:ruleRef` +- `/hooks/new` +- `/hooks/manage/*` +- `/hooks/discovered/:groupRef` + +This preserves old links, tests, and navigation assumptions while making `/resources` the primary surface. + +### Navigation changes + +The sidebar removes `Rules` and `Hooks` as top-level manage items. `Resources` remains the single entrypoint. + +Dashboard shortcuts or cards that currently point to `/rules` or `/hooks` may continue to work through redirects, but primary navigation should point to `/resources` tabs. + +## 2. Shared Managed-Resource Orchestration + +Introduce a new internal orchestration layer for managed `rules` and `hooks`. + +The package location can be finalized during implementation, but the responsibility should be narrow and explicit. A plausible home is: + +- `internal/resources/managed` + +This package should own shared application-level workflows for managed resources, not the lower-level format-specific compile logic. + +### Responsibilities + +The shared managed orchestration layer should provide: + +- managed resource selection and normalization for `rules`, `hooks`, or both +- sync planning and execution per target +- collect planning and execution from discovered inputs +- stable result structures for sync and collect +- dry-run behavior +- pruning behavior where applicable +- shared validation and collision detection paths where current CLI and server logic overlap + +### Non-responsibilities + +The shared layer should not: + +- replace the lower-level `managedrules` and `managedhooks` packages +- replace the existing skills sync path +- replace the existing agents sync path +- take ownership of skills or agents sync +- become responsible for HTTP concerns or terminal rendering + +## 3. Managed Sync Design + +Managed sync remains a separate concern from skills and agents sync, but uses one shared executor underneath both CLI and server callers. + +This is intentionally analogous to how agents already have a dedicated sync path in `v0.19.0`, rather than being folded into the skills implementation. + +### Shared sync input + +The sync executor should accept an explicit input object containing: + +- selected resources: `rules`, `hooks`, or both +- target set +- project root or global root context +- dry-run flag +- force flag if required by the calling surface + +### Shared sync behavior + +For each target: + +1. Resolve whether managed `rules` apply for that target. +2. Resolve whether managed `hooks` apply for that target. +3. Load managed records from the correct project or global store. +4. Compile target-specific output. +5. Apply compiled files. +6. Prune managed rule orphans where the target supports owned rule directories. +7. Return structured per-resource results. + +### Result model + +The shared result model should capture per target: + +- resource kind +- updated files +- skipped or unchanged files +- pruned files +- errors + +CLI and server code can then map this shared result into their own output shapes without rebuilding the core workflow. + +### CLI integration + +`cmd/skillshare/sync.go` and `cmd/skillshare/managed_resources.go` should stop directly owning the managed `rules` and `hooks` target workflows. + +They should instead: + +- parse CLI flags and resource selections +- prepare target entries and context +- call the shared managed sync executor +- format CLI summary lines and JSON output + +The CLI should continue to compose separate subsystems at the command layer: + +- skills on the existing skills path +- agents on the existing agents path +- rules/hooks on the managed-resource path + +### Server integration + +`internal/server/handler_sync.go` and `internal/server/managed_resource_sync.go` should stop duplicating the managed target workflow. + +They should instead: + +- parse HTTP body +- select resources +- call the shared managed sync executor +- return UI-oriented JSON results + +The server should continue to compose managed results with skills and agents results at the HTTP layer, because that composition is part of the existing `v0.19.0` server architecture. + +## 4. Managed Collect Design + +Managed collect currently exists in both CLI and server flows but is implemented through different input gathering paths. + +The shared collect layer should separate: + +- discovery selection +- managed collect execution + +### Shared collect input + +The collect executor should work on explicit discovered items already chosen by the caller: + +- discovered rule items +- discovered hook items or normalized hook groups +- strategy such as overwrite or skip +- project or global root context +- dry-run flag when supported + +This keeps the shared layer independent from whether the caller is a CLI that scanned the filesystem or an HTTP handler that received selected IDs from the UI. + +### CLI integration + +CLI collect continues to: + +- scan local skills as it already does +- scan discovered rules or hooks as needed +- resolve target or global context +- call the shared managed collect executor + +The CLI remains responsible for mixed flows like `skills + rules + hooks`, because that composition is part of the CLI command behavior. But the managed portion of that flow should not reimplement collect internals. + +### Server integration + +The server collect handlers for managed rules and hooks continue to: + +- validate request bodies +- map selected IDs or group IDs to discovered items +- call the shared managed collect executor +- return created, overwritten, and skipped results + +This keeps existing API semantics stable while removing branch-local duplication. + +## 5. Package and Boundary Plan + +The intended boundaries after refactor are: + +### Low-level resource packages + +- `internal/resources/rules` +- `internal/resources/hooks` +- `internal/resources/apply` + +These continue to own record storage, compile behavior, and low-level application helpers. + +### Shared orchestration package + +A new package owns: + +- managed sync execution +- managed collect execution +- common result types +- common target/resource selection helpers used specifically for managed resources + +This package is a sibling to the existing skills and agents orchestration paths, not a replacement for them. + +### CLI layer + +The CLI layer owns: + +- flag parsing +- command routing +- terminal rendering +- CLI JSON output shape +- composition with skills, agents, and extras flows already established in `v0.19.0` + +### Server layer + +The server layer owns: + +- HTTP request parsing and validation +- response formatting +- route registration +- composition of managed results with existing UI sync responses + +## 6. Testing Strategy + +The refactor should be test-led around behavior preservation. + +### Shared orchestration tests + +Add focused tests for the new shared managed orchestration package covering: + +- sync rules only +- sync hooks only +- sync rules and hooks together +- collect rules only +- collect hooks only +- dry-run behavior +- target incompatibility behavior +- rule prune behavior +- collect collision and invalid input behavior + +### CLI regression tests + +Update or preserve CLI tests to ensure: + +- `sync --resources rules` +- `sync --resources hooks` +- `sync --resources rules,hooks` +- `collect --resources rules` +- `collect --resources hooks` +- `collect --resources skills,rules,hooks` + +all still behave as before from the user's perspective. + +### Server regression tests + +Preserve or add tests for: + +- `/api/sync` with managed resources selected +- managed rule collect endpoint +- managed hook collect endpoint + +### UI tests + +Add or update tests for: + +- `/resources` tab switching across `skills`, `agents`, `rules`, and `hooks` +- removal of `Rules` and `Hooks` from primary sidebar navigation +- compatibility redirects from `/rules` and `/hooks` +- existing new/manage/discovered rule and hook routes + +## 7. Migration and Rollout + +This refactor should be implemented in the following order: + +1. Extract shared managed sync executor and migrate CLI plus server sync call sites. +2. Extract shared managed collect executor and migrate CLI plus server collect call sites. +3. Refactor `/resources` to host `rules` and `hooks` tabs using the `v0.19.0` shell. +4. Convert `/rules` and `/hooks` list routes into compatibility redirects or thin wrappers. +5. Remove `Rules` and `Hooks` from sidebar primary navigation. +6. Update tests last to reflect the final route and navigation model while preserving compatibility. + +This order keeps the highest-risk backend changes isolated from the UI consolidation and makes regressions easier to localize. + +## 8. Error Handling + +### Backend + +- Shared executors should return typed or structured errors where the caller needs to distinguish invalid input from execution failures. +- HTTP handlers keep responsibility for mapping invalid input to `400` and execution failures to `500`. +- CLI callers keep responsibility for rendering warnings, failures, and JSON error output in the existing style. + +### UI + +- `/resources` tab content should preserve current empty states and fetch error behavior. +- Compatibility redirects should be transparent and should not alter existing rule or hook editor routes. + +## 9. Risks and Mitigations + +### Risk: over-generalizing the shared layer + +Mitigation: +Keep the new package scoped to managed `rules` and `hooks` only. Do not force skills, agents, or extras into it. + +### Risk: breaking existing routes and tests + +Mitigation: +Keep detail and editor routes stable and add compatibility redirects for `/rules` and `/hooks`. + +### Risk: changing CLI behavior while cleaning up internals + +Mitigation: +Treat current CLI behavior as the compatibility surface and verify it with regression tests before and after refactor. + +### Risk: UI consolidation becomes a design rewrite + +Mitigation: +Use the existing `ResourcesPage` shell from `v0.19.0` as the only list-page visual model. Do not create a second competing shell. + +## 10. Success Criteria + +This refactor is successful when all of the following are true: + +- `rules` and `hooks` are accessed from `/resources` tabs as part of the standardized `v0.19.0` management surface +- sidebar primary navigation no longer treats `Rules` and `Hooks` as separate peer pages +- CLI sync and collect for managed `rules` and `hooks` use shared internal orchestration +- server sync and managed collect endpoints use the same shared internal orchestration +- managed rule and hook detail/edit routes still work +- tests pass without changing the intended behavior of existing user-facing commands and APIs + +## Out of Scope Reminder + +Inline markdown editing in the Skillshare UI, including editing `SKILL.md` inside a modal, is intentionally excluded from this branch and should remain on the separate worktree or PR dedicated to that feature. diff --git a/go.mod b/go.mod index 93e8314b..3fb851a6 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,9 @@ require ( github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/x/ansi v0.11.6 github.com/mattn/go-runewidth v0.0.19 + github.com/pelletier/go-toml/v2 v2.2.3 github.com/pterm/pterm v0.12.82 + github.com/pkoukk/tiktoken-go v0.1.8 github.com/sergi/go-diff v1.4.0 golang.org/x/sys v0.38.0 golang.org/x/term v0.32.0 @@ -34,6 +36,7 @@ require ( github.com/containerd/console v1.0.5 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/uuid v1.3.0 // indirect github.com/gookit/color v1.5.4 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect diff --git a/go.sum b/go.sum index cb0636c3..66820759 100644 --- a/go.sum +++ b/go.sum @@ -66,6 +66,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= @@ -108,6 +110,10 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo= +github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= @@ -132,8 +138,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= diff --git a/internal/backup/backup.go b/internal/backup/backup.go index a75cfd4d..e3fe5968 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -53,9 +53,11 @@ func CreateInDir(backupDir, targetName, targetPath string) (string, error) { return "", nil // Empty, nothing to backup } - // Create backup directory with timestamp - timestamp := time.Now().Format("2006-01-02_15-04-05") - backupPath := filepath.Join(backupDir, timestamp, targetName) + // Create backup directory with a unique timestamped target path. + _, backupPath, err := allocateBackupPath(backupDir, targetName) + if err != nil { + return "", fmt.Errorf("failed to allocate backup path: %w", err) + } if err := os.MkdirAll(backupPath, 0755); err != nil { return "", fmt.Errorf("failed to create backup directory: %w", err) @@ -168,7 +170,7 @@ func ListTargetsWithBackups(backupDir string) ([]TargetBackupSummary, error) { continue } - ts, parseErr := time.ParseInLocation("2006-01-02_15-04-05", entry.Name(), time.Local) + ts, parseErr := parseBackupTimestamp(entry.Name()) if parseErr != nil { continue // skip directories that don't match the timestamp format } @@ -178,11 +180,19 @@ func ListTargetsWithBackups(backupDir string) ([]TargetBackupSummary, error) { continue } + seenTargets := make(map[string]struct{}, len(targetEntries)) for _, te := range targetEntries { if !te.IsDir() { continue } name := te.Name() + if canonical, ok := config.CanonicalTargetName(name); ok { + name = canonical + } + if _, ok := seenTargets[name]; ok { + continue + } + seenTargets[name] = struct{}{} acc, ok := targets[name] if !ok { acc = &accumulator{oldest: ts, latest: ts} diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index f2467f5d..4819d320 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -1,8 +1,10 @@ package backup import ( + "encoding/json" "os" "path/filepath" + "strings" "testing" "time" ) @@ -219,6 +221,402 @@ func TestValidateRestore_SymlinkTarget_IsAllowed(t *testing.T) { } } +func TestRestoreToPath_ManifestSnapshotRestoresSiblingPathsAndAbsence(t *testing.T) { + root := t.TempDir() + backupPath := filepath.Join(root, "backup") + targetBackupPath := filepath.Join(backupPath, "claude") + destPath := filepath.Join(root, "home", ".claude", "skills") + + if err := os.MkdirAll(filepath.Join(targetBackupPath, "entries", "skills", "alpha"), 0755); err != nil { + t.Fatalf("mkdir manifest skill entry: %v", err) + } + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "skills", "alpha", "SKILL.md"), "# Alpha") + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "settings.json"), `{"hooks":{"PreToolUse":[]}}`) + if err := os.MkdirAll(filepath.Join(targetBackupPath, "entries", "rules"), 0755); err != nil { + t.Fatalf("mkdir manifest rule entry: %v", err) + } + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "rules", "manual.md"), "# Managed rule") + + writeBackupManifest(t, filepath.Join(targetBackupPath, snapshotManifestFilename), map[string]any{ + "version": 1, + "entries": []map[string]any{ + {"relative_path": "skills", "kind": "dir", "storage_path": "entries/skills"}, + {"relative_path": "settings.json", "kind": "file", "storage_path": "entries/settings.json"}, + {"relative_path": "rules", "kind": "dir", "storage_path": "entries/rules"}, + {"relative_path": "hooks.json", "kind": "absent"}, + }, + }) + + if err := os.MkdirAll(filepath.Join(destPath, "local"), 0755); err != nil { + t.Fatalf("mkdir local skill: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, "home", ".claude", "rules"), 0755); err != nil { + t.Fatalf("mkdir rules dir: %v", err) + } + writeTestFile(t, filepath.Join(destPath, "local", "SKILL.md"), "# Local") + writeTestFile(t, filepath.Join(root, "home", ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash"}]}}`) + writeTestFile(t, filepath.Join(root, "home", ".claude", "rules", "manual.md"), "# Old rule") + writeTestFile(t, filepath.Join(root, "home", ".claude", "hooks.json"), `{"stale":true}`) + + if err := RestoreToPath(backupPath, "claude", destPath, RestoreOptions{Force: true}); err != nil { + t.Fatalf("RestoreToPath(manifest snapshot) error = %v", err) + } + + assertFileContent(t, filepath.Join(destPath, "alpha", "SKILL.md"), "# Alpha") + assertFileContent(t, filepath.Join(root, "home", ".claude", "settings.json"), `{"hooks":{"PreToolUse":[]}}`) + assertFileContent(t, filepath.Join(root, "home", ".claude", "rules", "manual.md"), "# Managed rule") + if _, err := os.Stat(filepath.Join(root, "home", ".claude", "hooks.json")); !os.IsNotExist(err) { + t.Fatalf("expected hooks.json to be removed by absence tombstone, err=%v", err) + } +} + +func TestRestoreToPath_LegacyRestoreFailureKeepsExistingDestination(t *testing.T) { + root := t.TempDir() + backupPath := filepath.Join(root, "backup") + targetBackupPath := filepath.Join(backupPath, "claude") + destPath := filepath.Join(root, "home", ".claude", "skills") + + if err := os.MkdirAll(filepath.Join(targetBackupPath, "alpha"), 0o755); err != nil { + t.Fatalf("mkdir backup skill: %v", err) + } + writeTestFile(t, filepath.Join(targetBackupPath, "alpha", "SKILL.md"), "# Alpha") + if err := os.MkdirAll(filepath.Join(destPath, "local"), 0o755); err != nil { + t.Fatalf("mkdir existing skill: %v", err) + } + writeTestFile(t, filepath.Join(destPath, "local", "SKILL.md"), "# Local") + + originalCopyDir := restoreCopyDir + restoreCopyDir = func(src, dst string) error { + return os.ErrPermission + } + t.Cleanup(func() { + restoreCopyDir = originalCopyDir + }) + + err := RestoreToPath(backupPath, "claude", destPath, RestoreOptions{Force: true}) + if err == nil { + t.Fatal("RestoreToPath() error = nil, want staged copy failure") + } + + assertFileContent(t, filepath.Join(destPath, "local", "SKILL.md"), "# Local") + if _, statErr := os.Stat(filepath.Join(destPath, "alpha", "SKILL.md")); !os.IsNotExist(statErr) { + t.Fatalf("expected staged restore failure to avoid partial destination update, err=%v", statErr) + } +} + +func TestRestoreToPath_ManifestSnapshotRestoreBaseModeGrandparent(t *testing.T) { + root := t.TempDir() + backupPath := filepath.Join(root, "backup") + targetBackupPath := filepath.Join(backupPath, "universal") + destPath := filepath.Join(root, "home", ".agents", "skills") + + if err := os.MkdirAll(filepath.Join(targetBackupPath, "entries", "skills", "alpha"), 0755); err != nil { + t.Fatalf("mkdir manifest skill entry: %v", err) + } + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "skills", "alpha", "SKILL.md"), "# Alpha") + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "config.toml"), "[features]\ncodex_hooks = true\n") + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "hooks.json"), `{"hooks":{"PreToolUse":[]}}`) + + writeBackupManifest(t, filepath.Join(targetBackupPath, snapshotManifestFilename), map[string]any{ + "version": 1, + "restore_base_mode": "grandparent", + "entries": []map[string]any{ + {"relative_path": ".agents/skills", "kind": "dir", "storage_path": "entries/skills"}, + {"relative_path": ".codex/config.toml", "kind": "file", "storage_path": "entries/config.toml"}, + {"relative_path": ".codex/hooks.json", "kind": "file", "storage_path": "entries/hooks.json"}, + }, + }) + + if err := os.MkdirAll(filepath.Join(root, "home", ".codex"), 0755); err != nil { + t.Fatalf("mkdir codex dir: %v", err) + } + writeTestFile(t, filepath.Join(root, "home", ".codex", "config.toml"), "[features]\ncodex_hooks = false\n") + writeTestFile(t, filepath.Join(root, "home", ".codex", "hooks.json"), `{"stale":true}`) + + if err := RestoreToPath(backupPath, "universal", destPath, RestoreOptions{Force: true}); err != nil { + t.Fatalf("RestoreToPath(manifest snapshot with grandparent base) error = %v", err) + } + + assertFileContent(t, filepath.Join(destPath, "alpha", "SKILL.md"), "# Alpha") + assertFileContent(t, filepath.Join(root, "home", ".codex", "config.toml"), "[features]\ncodex_hooks = true\n") + assertFileContent(t, filepath.Join(root, "home", ".codex", "hooks.json"), `{"hooks":{"PreToolUse":[]}}`) +} + +func TestRestoreToPath_ManifestSnapshotFileRestoreFailureKeepsExistingFile(t *testing.T) { + root := t.TempDir() + backupPath := filepath.Join(root, "backup") + targetBackupPath := filepath.Join(backupPath, "claude") + destPath := filepath.Join(root, "home", ".claude", "skills") + settingsPath := filepath.Join(root, "home", ".claude", "settings.json") + + if err := os.MkdirAll(filepath.Join(targetBackupPath, "entries"), 0o755); err != nil { + t.Fatalf("mkdir manifest entries: %v", err) + } + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "settings.json"), `{"hooks":{"PreToolUse":[]}}`) + writeBackupManifest(t, filepath.Join(targetBackupPath, snapshotManifestFilename), map[string]any{ + "version": 1, + "entries": []map[string]any{ + {"relative_path": "settings.json", "kind": "file", "storage_path": "entries/settings.json"}, + }, + }) + + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil { + t.Fatalf("mkdir settings parent: %v", err) + } + writeTestFile(t, settingsPath, `{"hooks":{"PreToolUse":[{"matcher":"Bash"}]}}`) + + originalCopyFile := restoreCopyFile + restoreCopyFile = func(src, dst string) error { + return os.ErrPermission + } + t.Cleanup(func() { + restoreCopyFile = originalCopyFile + }) + + err := RestoreToPath(backupPath, "claude", destPath, RestoreOptions{Force: true}) + if err == nil { + t.Fatal("RestoreToPath() error = nil, want staged file restore failure") + } + + assertFileContent(t, settingsPath, `{"hooks":{"PreToolUse":[{"matcher":"Bash"}]}}`) +} + +func TestRestoreToPath_ManifestSnapshotRestoreBaseModeGrandparentRejectsDifferentSkillsTree(t *testing.T) { + root := t.TempDir() + backupPath := filepath.Join(root, "backup") + targetBackupPath := filepath.Join(backupPath, "universal") + destPath := filepath.Join(root, "home", ".other-agents", "skills") + + if err := os.MkdirAll(filepath.Join(targetBackupPath, "entries", "skills", "alpha"), 0755); err != nil { + t.Fatalf("mkdir manifest skill entry: %v", err) + } + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "skills", "alpha", "SKILL.md"), "# Alpha") + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "config.toml"), "[features]\ncodex_hooks = true\n") + + writeBackupManifest(t, filepath.Join(targetBackupPath, snapshotManifestFilename), map[string]any{ + "version": 1, + "restore_base_mode": "grandparent", + "target_relative_path": ".agents/skills", + "entries": []map[string]any{ + {"relative_path": ".agents/skills", "kind": "dir", "storage_path": "entries/skills"}, + {"relative_path": ".codex/config.toml", "kind": "file", "storage_path": "entries/config.toml"}, + }, + }) + + err := RestoreToPath(backupPath, "universal", destPath, RestoreOptions{Force: true}) + if err == nil { + t.Fatal("RestoreToPath should reject restoring into a different .../skills tree") + } + if !strings.Contains(err.Error(), "target path") { + t.Fatalf("RestoreToPath error = %v, want clear target path mismatch", err) + } + if _, statErr := os.Stat(filepath.Join(destPath, "alpha", "SKILL.md")); !os.IsNotExist(statErr) { + t.Fatalf("expected mismatched destination tree to remain untouched, err=%v", statErr) + } +} + +func TestRestoreToPath_LegacyManagedOnlyUniversalSnapshotRestoresToCanonicalPath(t *testing.T) { + root := t.TempDir() + backupPath := filepath.Join(root, "backup") + targetBackupPath := filepath.Join(backupPath, "universal") + destPath := filepath.Join(root, "home", ".agents", "skills") + + if err := os.MkdirAll(filepath.Join(targetBackupPath, "entries"), 0o755); err != nil { + t.Fatalf("mkdir manifest entries dir: %v", err) + } + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "config.toml"), "[features]\ncodex_hooks = true\n") + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "hooks.json"), `{"hooks":{"PreToolUse":[]}}`) + + writeBackupManifest(t, filepath.Join(targetBackupPath, snapshotManifestFilename), map[string]any{ + "version": 1, + "restore_base_mode": "grandparent", + "entries": []map[string]any{ + {"relative_path": ".codex/config.toml", "kind": "file", "storage_path": "entries/config.toml"}, + {"relative_path": ".codex/hooks.json", "kind": "file", "storage_path": "entries/hooks.json"}, + }, + }) + + if err := os.MkdirAll(filepath.Join(root, "home", ".codex"), 0o755); err != nil { + t.Fatalf("mkdir codex dir: %v", err) + } + writeTestFile(t, filepath.Join(root, "home", ".codex", "config.toml"), "[features]\ncodex_hooks = false\n") + + if err := RestoreToPath(backupPath, "universal", destPath, RestoreOptions{Force: true}); err != nil { + t.Fatalf("RestoreToPath(legacy managed-only universal snapshot) error = %v", err) + } + + assertFileContent(t, filepath.Join(root, "home", ".codex", "config.toml"), "[features]\ncodex_hooks = true\n") + assertFileContent(t, filepath.Join(root, "home", ".codex", "hooks.json"), `{"hooks":{"PreToolUse":[]}}`) +} + +func TestRestoreToPath_LegacyManagedOnlyUniversalSnapshotRestoresViaAlias(t *testing.T) { + root := t.TempDir() + backupPath := filepath.Join(root, "backup") + targetBackupPath := filepath.Join(backupPath, "universal") + destPath := filepath.Join(root, "home", ".agents", "skills") + + if err := os.MkdirAll(filepath.Join(targetBackupPath, "entries"), 0o755); err != nil { + t.Fatalf("mkdir manifest entries dir: %v", err) + } + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "config.toml"), "[features]\ncodex_hooks = true\n") + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "hooks.json"), `{"hooks":{"PreToolUse":[]}}`) + + writeBackupManifest(t, filepath.Join(targetBackupPath, snapshotManifestFilename), map[string]any{ + "version": 1, + "restore_base_mode": "grandparent", + "entries": []map[string]any{ + {"relative_path": ".codex/config.toml", "kind": "file", "storage_path": "entries/config.toml"}, + {"relative_path": ".codex/hooks.json", "kind": "file", "storage_path": "entries/hooks.json"}, + }, + }) + + if err := os.MkdirAll(filepath.Join(root, "home", ".codex"), 0o755); err != nil { + t.Fatalf("mkdir codex dir: %v", err) + } + writeTestFile(t, filepath.Join(root, "home", ".codex", "config.toml"), "[features]\ncodex_hooks = false\n") + + if err := RestoreToPath(backupPath, "agents", destPath, RestoreOptions{Force: true}); err != nil { + t.Fatalf("RestoreToPath(legacy managed-only universal snapshot via alias) error = %v", err) + } + + assertFileContent(t, filepath.Join(root, "home", ".codex", "config.toml"), "[features]\ncodex_hooks = true\n") + assertFileContent(t, filepath.Join(root, "home", ".codex", "hooks.json"), `{"hooks":{"PreToolUse":[]}}`) +} + +func TestRestoreToPath_LegacyManagedOnlyUniversalSnapshotRejectsDifferentTargetPath(t *testing.T) { + root := t.TempDir() + backupPath := filepath.Join(root, "backup") + targetBackupPath := filepath.Join(backupPath, "universal") + destPath := filepath.Join(root, "home", ".other-agents", "skills") + + if err := os.MkdirAll(filepath.Join(targetBackupPath, "entries"), 0o755); err != nil { + t.Fatalf("mkdir manifest entries dir: %v", err) + } + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "config.toml"), "[features]\ncodex_hooks = true\n") + + writeBackupManifest(t, filepath.Join(targetBackupPath, snapshotManifestFilename), map[string]any{ + "version": 1, + "restore_base_mode": "grandparent", + "entries": []map[string]any{ + {"relative_path": ".codex/config.toml", "kind": "file", "storage_path": "entries/config.toml"}, + }, + }) + + err := RestoreToPath(backupPath, "universal", destPath, RestoreOptions{Force: true}) + if err == nil { + t.Fatal("RestoreToPath should reject restoring a legacy managed-only universal snapshot into a different skills tree") + } + if !strings.Contains(err.Error(), "target path") { + t.Fatalf("RestoreToPath error = %v, want clear target path mismatch", err) + } + if _, statErr := os.Stat(filepath.Join(root, "home", ".codex", "config.toml")); !os.IsNotExist(statErr) { + t.Fatalf("expected mismatched destination tree to remain untouched, err=%v", statErr) + } +} + +func TestRestoreToPath_LegacyManagedOnlyUniversalSnapshotRejectsDifferentTargetPathViaAlias(t *testing.T) { + root := t.TempDir() + backupPath := filepath.Join(root, "backup") + targetBackupPath := filepath.Join(backupPath, "universal") + destPath := filepath.Join(root, "home", ".other-agents", "skills") + + if err := os.MkdirAll(filepath.Join(targetBackupPath, "entries"), 0o755); err != nil { + t.Fatalf("mkdir manifest entries dir: %v", err) + } + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "config.toml"), "[features]\ncodex_hooks = true\n") + + writeBackupManifest(t, filepath.Join(targetBackupPath, snapshotManifestFilename), map[string]any{ + "version": 1, + "restore_base_mode": "grandparent", + "entries": []map[string]any{ + {"relative_path": ".codex/config.toml", "kind": "file", "storage_path": "entries/config.toml"}, + }, + }) + + err := RestoreToPath(backupPath, "agents", destPath, RestoreOptions{Force: true}) + if err == nil { + t.Fatal("RestoreToPath should reject restoring a legacy managed-only universal snapshot via alias into a different skills tree") + } + if !strings.Contains(err.Error(), "target path") { + t.Fatalf("RestoreToPath(alias) error = %v, want clear target path mismatch", err) + } + if _, statErr := os.Stat(filepath.Join(root, "home", ".codex", "config.toml")); !os.IsNotExist(statErr) { + t.Fatalf("expected mismatched destination tree to remain untouched, err=%v", statErr) + } +} + +func TestValidateRestore_ManifestSnapshotRejectsRelativePathTraversal(t *testing.T) { + root := t.TempDir() + backupPath := filepath.Join(root, "backup") + targetBackupPath := filepath.Join(backupPath, "claude") + destPath := filepath.Join(root, "home", ".claude", "skills") + + if err := os.MkdirAll(filepath.Join(targetBackupPath, "entries"), 0755); err != nil { + t.Fatalf("mkdir manifest entries dir: %v", err) + } + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "alpha"), "payload") + writeBackupManifest(t, filepath.Join(targetBackupPath, snapshotManifestFilename), map[string]any{ + "version": 1, + "entries": []map[string]any{ + {"relative_path": "../escape.txt", "kind": "file", "storage_path": "entries/alpha"}, + }, + }) + + err := ValidateRestore(backupPath, "claude", destPath, RestoreOptions{Force: true}) + if err == nil { + t.Fatal("ValidateRestore should reject relative_path traversal") + } +} + +func TestRestoreToPath_ManifestSnapshotRejectsStoragePathTraversal(t *testing.T) { + root := t.TempDir() + backupPath := filepath.Join(root, "backup") + targetBackupPath := filepath.Join(backupPath, "claude") + destPath := filepath.Join(root, "home", ".claude", "skills") + outsidePath := filepath.Join(root, "outside.txt") + + writeTestFile(t, outsidePath, "outside") + writeBackupManifest(t, filepath.Join(targetBackupPath, snapshotManifestFilename), map[string]any{ + "version": 1, + "entries": []map[string]any{ + {"relative_path": "skills", "kind": "dir", "storage_path": "entries/skills"}, + {"relative_path": "settings.json", "kind": "file", "storage_path": "../../outside.txt"}, + }, + }) + if err := os.MkdirAll(filepath.Join(targetBackupPath, "entries", "skills", "alpha"), 0755); err != nil { + t.Fatalf("mkdir skills entry: %v", err) + } + writeTestFile(t, filepath.Join(targetBackupPath, "entries", "skills", "alpha", "SKILL.md"), "# Alpha") + + err := RestoreToPath(backupPath, "claude", destPath, RestoreOptions{Force: true}) + if err == nil { + t.Fatal("RestoreToPath should reject storage_path traversal") + } + if _, statErr := os.Stat(filepath.Join(root, "home", ".claude", "settings.json")); !os.IsNotExist(statErr) { + t.Fatalf("expected no restored settings.json, err=%v", statErr) + } +} + +func TestRestoreToPath_LegacyBackupWithSyncManifestFileStillRestores(t *testing.T) { + root := t.TempDir() + backupPath := filepath.Join(root, "backup") + targetBackupPath := filepath.Join(backupPath, "claude") + destPath := filepath.Join(root, "restore") + + if err := os.MkdirAll(filepath.Join(targetBackupPath, "alpha"), 0755); err != nil { + t.Fatalf("mkdir legacy backup skill: %v", err) + } + writeTestFile(t, filepath.Join(targetBackupPath, "alpha", "SKILL.md"), "# Alpha") + writeTestFile(t, filepath.Join(targetBackupPath, ".skillshare-manifest.json"), `{"managed":{"alpha":"abc123"}}`) + + if err := RestoreToPath(backupPath, "claude", destPath, RestoreOptions{Force: true}); err != nil { + t.Fatalf("RestoreToPath(legacy backup with sync manifest) error = %v", err) + } + + assertFileContent(t, filepath.Join(destPath, "alpha", "SKILL.md"), "# Alpha") + assertFileContent(t, filepath.Join(destPath, ".skillshare-manifest.json"), `{"managed":{"alpha":"abc123"}}`) +} + func TestListTargetsWithBackups_Empty(t *testing.T) { dir := t.TempDir() @@ -244,8 +642,7 @@ func TestListTargetsWithBackups_NonExistentDir(t *testing.T) { func TestListTargetsWithBackups_MultiBacks(t *testing.T) { dir := t.TempDir() - // Create 3 timestamp directories with various targets - // Timestamp format matches backup.Create: 2006-01-02_15-04-05 + // Create 3 timestamp directories with various targets. timestamps := []string{ "2025-01-10_08-00-00", "2025-02-15_12-30-00", @@ -307,6 +704,30 @@ func TestListTargetsWithBackups_MultiBacks(t *testing.T) { } } +func TestListTargetsWithBackups_SupportsMillisecondTimestamps(t *testing.T) { + dir := t.TempDir() + + os.MkdirAll(filepath.Join(dir, "2025-01-10_08-00-00.125", "claude"), 0o755) + os.MkdirAll(filepath.Join(dir, "2025-01-10_08-00-00.875", "claude"), 0o755) + + summaries, err := ListTargetsWithBackups(dir) + if err != nil { + t.Fatalf("ListTargetsWithBackups(ms timestamps) error = %v", err) + } + if len(summaries) != 1 { + t.Fatalf("expected 1 summary, got %d", len(summaries)) + } + + wantOldest := time.Date(2025, 1, 10, 8, 0, 0, 125_000_000, time.Local) + wantLatest := time.Date(2025, 1, 10, 8, 0, 0, 875_000_000, time.Local) + if !summaries[0].Oldest.Equal(wantOldest) { + t.Fatalf("Oldest = %v, want %v", summaries[0].Oldest, wantOldest) + } + if !summaries[0].Latest.Equal(wantLatest) { + t.Fatalf("Latest = %v, want %v", summaries[0].Latest, wantLatest) + } +} + func TestListTargetsWithBackups_SkipsFiles(t *testing.T) { dir := t.TempDir() @@ -329,6 +750,32 @@ func TestListTargetsWithBackups_SkipsFiles(t *testing.T) { } } +func TestListTargetsWithBackups_GroupsAliasAndCanonicalTargetNames(t *testing.T) { + dir := t.TempDir() + + os.MkdirAll(filepath.Join(dir, "2025-01-10_08-00-00", "agents"), 0o755) + os.MkdirAll(filepath.Join(dir, "2025-02-15_12-30-00", "universal"), 0o755) + os.MkdirAll(filepath.Join(dir, "2025-03-20_18-45-00", "codex"), 0o755) + + summaries, err := ListTargetsWithBackups(dir) + if err != nil { + t.Fatalf("ListTargetsWithBackups(alias grouping) error = %v", err) + } + + if len(summaries) != 2 { + t.Fatalf("expected 2 grouped targets, got %d", len(summaries)) + } + if summaries[0].TargetName != "codex" { + t.Fatalf("summaries[0].TargetName = %q, want %q", summaries[0].TargetName, "codex") + } + if summaries[1].TargetName != "universal" { + t.Fatalf("summaries[1].TargetName = %q, want %q", summaries[1].TargetName, "universal") + } + if summaries[1].BackupCount != 2 { + t.Fatalf("universal BackupCount = %d, want 2", summaries[1].BackupCount) + } +} + func TestListBackupVersions_Empty(t *testing.T) { dir := t.TempDir() @@ -423,6 +870,94 @@ func TestListBackupVersions_ReturnsSkillInfo(t *testing.T) { } } +func TestListBackupVersions_PrefersExactBackupDirectoryName(t *testing.T) { + dir := t.TempDir() + + aliasSkillDir := filepath.Join(dir, "2025-03-20_18-45-00", "agents", "alias-skill") + canonicalSkillDir := filepath.Join(dir, "2025-03-20_18-45-00", "universal", "canonical-skill") + if err := os.MkdirAll(aliasSkillDir, 0o755); err != nil { + t.Fatalf("mkdir alias skill dir: %v", err) + } + if err := os.MkdirAll(canonicalSkillDir, 0o755); err != nil { + t.Fatalf("mkdir canonical skill dir: %v", err) + } + writeTestFile(t, filepath.Join(aliasSkillDir, "SKILL.md"), "# Alias") + writeTestFile(t, filepath.Join(canonicalSkillDir, "SKILL.md"), "# Canonical") + + result, err := ListBackupVersions(dir, "agents") + if err != nil { + t.Fatalf("ListBackupVersions(exact backup dir) error = %v", err) + } + if len(result) != 1 { + t.Fatalf("expected 1 version, got %d", len(result)) + } + if got := filepath.Base(result[0].Dir); got != "agents" { + t.Fatalf("result[0].Dir basename = %q, want %q", got, "agents") + } + if len(result[0].SkillNames) != 1 || result[0].SkillNames[0] != "alias-skill" { + t.Fatalf("result[0].SkillNames = %v, want [alias-skill]", result[0].SkillNames) + } +} + +func TestListBackupVersions_ManifestSnapshotUsesSkillEntryContents(t *testing.T) { + dir := t.TempDir() + ts := "2025-03-20_18-45-00" + targetDir := filepath.Join(dir, ts, "claude") + + if err := os.MkdirAll(filepath.Join(targetDir, "entries", "skills", "skill-a"), 0755); err != nil { + t.Fatalf("mkdir skill-a: %v", err) + } + if err := os.MkdirAll(filepath.Join(targetDir, "entries", "skills", "skill-b"), 0755); err != nil { + t.Fatalf("mkdir skill-b: %v", err) + } + writeTestFile(t, filepath.Join(targetDir, "entries", "skills", "skill-a", "SKILL.md"), "# Skill A") + writeTestFile(t, filepath.Join(targetDir, "entries", "skills", "skill-b", "SKILL.md"), "# Skill B") + writeTestFile(t, filepath.Join(targetDir, "entries", "settings.json"), `{"hooks":{"PreToolUse":[]}}`) + + writeBackupManifest(t, filepath.Join(targetDir, snapshotManifestFilename), map[string]any{ + "version": 1, + "entries": []map[string]any{ + {"relative_path": "skills", "kind": "dir", "storage_path": "entries/skills"}, + {"relative_path": "settings.json", "kind": "file", "storage_path": "entries/settings.json"}, + }, + }) + + result, err := ListBackupVersions(dir, "claude") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 1 { + t.Fatalf("expected 1 version, got %d", len(result)) + } + if result[0].SkillCount != 2 { + t.Fatalf("SkillCount = %d, want 2", result[0].SkillCount) + } + if len(result[0].SkillNames) != 2 || result[0].SkillNames[0] != "skill-a" || result[0].SkillNames[1] != "skill-b" { + t.Fatalf("SkillNames = %v, want [skill-a skill-b]", result[0].SkillNames) + } +} + +func TestListBackupVersions_FormatsMillisecondLabels(t *testing.T) { + dir := t.TempDir() + ts := "2025-03-20_18-45-00.125" + skillDir := filepath.Join(dir, ts, "claude", "skill-a") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("mkdir skill dir: %v", err) + } + writeTestFile(t, filepath.Join(skillDir, "SKILL.md"), "# Skill A") + + result, err := ListBackupVersions(dir, "claude") + if err != nil { + t.Fatalf("ListBackupVersions(ms label) error = %v", err) + } + if len(result) != 1 { + t.Fatalf("expected 1 version, got %d", len(result)) + } + if result[0].Label != "2025-03-20 18:45:00.125" { + t.Fatalf("Label = %q, want %q", result[0].Label, "2025-03-20 18:45:00.125") + } +} + func TestListBackupVersions_IgnoresOtherTargets(t *testing.T) { dir := t.TempDir() @@ -467,6 +1002,43 @@ func TestListBackupVersions_SkipsInvalidTimestamps(t *testing.T) { } } +func TestParseSnapshotManifest_TargetRelativePath(t *testing.T) { + manifest, ok, err := parseSnapshotManifest([]byte(`{ + "version": 1, + "restore_base_mode": "grandparent", + "target_relative_path": ".agents/skills", + "entries": [ + {"relative_path": ".agents/skills", "kind": "dir", "storage_path": "entries/skills"} + ] + }`), false) + if err != nil { + t.Fatalf("parseSnapshotManifest(new metadata) error = %v", err) + } + if !ok { + t.Fatal("parseSnapshotManifest(new metadata) = not a snapshot manifest, want snapshot manifest") + } + if manifest.TargetRelativePath != filepath.Join(".agents", "skills") { + t.Fatalf("TargetRelativePath = %q, want %q", manifest.TargetRelativePath, filepath.Join(".agents", "skills")) + } + + legacy, ok, err := parseSnapshotManifest([]byte(`{ + "version": 1, + "restore_base_mode": "grandparent", + "entries": [ + {"relative_path": ".agents/skills", "kind": "dir", "storage_path": "entries/skills"} + ] + }`), false) + if err != nil { + t.Fatalf("parseSnapshotManifest(legacy metadata) error = %v", err) + } + if !ok { + t.Fatal("parseSnapshotManifest(legacy metadata) = not a snapshot manifest, want snapshot manifest") + } + if legacy.TargetRelativePath != "" { + t.Fatalf("legacy TargetRelativePath = %q, want empty", legacy.TargetRelativePath) + } +} + // --- helpers --- func writeTestFile(t *testing.T, path, content string) { @@ -486,3 +1058,17 @@ func assertFileContent(t *testing.T, path, expected string) { t.Errorf("%s: got %q, want %q", path, string(data), expected) } } + +func writeBackupManifest(t *testing.T, path string, manifest map[string]any) { + t.Helper() + data, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("mkdir manifest dir: %v", err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatalf("write manifest: %v", err) + } +} diff --git a/internal/backup/manifest.go b/internal/backup/manifest.go new file mode 100644 index 00000000..bdcfeb7a --- /dev/null +++ b/internal/backup/manifest.go @@ -0,0 +1,327 @@ +package backup + +import ( + "encoding/json" + "fmt" + "os" + "path" + "path/filepath" + "sort" + "strings" +) + +const ( + snapshotManifestFilename = ".skillshare-backup-snapshot.json" + legacySnapshotManifestFilename = ".skillshare-manifest.json" + snapshotManifestVersion = 1 +) + +type SnapshotRestoreBaseMode string + +const ( + SnapshotRestoreBaseTarget SnapshotRestoreBaseMode = "target" + SnapshotRestoreBaseParent SnapshotRestoreBaseMode = "parent" + SnapshotRestoreBaseGrandparent SnapshotRestoreBaseMode = "grandparent" +) + +type SnapshotOptions struct { + RestoreBaseMode SnapshotRestoreBaseMode + TargetRelativePath string +} + +type SnapshotPath struct { + RelativePath string + SourcePath string + FollowTopSymlinks bool +} + +type snapshotManifest struct { + Version int `json:"version"` + RestoreBaseMode SnapshotRestoreBaseMode `json:"restore_base_mode,omitempty"` + TargetRelativePath string `json:"target_relative_path,omitempty"` + Entries []snapshotManifestEntry `json:"entries"` +} + +type snapshotManifestEntry struct { + RelativePath string `json:"relative_path"` + Kind string `json:"kind"` + StoragePath string `json:"storage_path,omitempty"` +} + +func CreateSnapshot(targetName string, paths []SnapshotPath, opts SnapshotOptions) (string, error) { + paths = normalizeSnapshotPaths(paths) + if len(paths) == 0 { + return "", nil + } + + targetRelativePath := opts.TargetRelativePath + if strings.TrimSpace(targetRelativePath) == "" { + switch opts.RestoreBaseMode { + case "", SnapshotRestoreBaseTarget: + targetRelativePath = "." + default: + return "", fmt.Errorf("snapshot target relative path is required for restore base mode %s", opts.RestoreBaseMode) + } + } + cleanTargetRelativePath, err := validateSnapshotRelativePath(targetRelativePath) + if err != nil { + return "", fmt.Errorf("invalid snapshot target relative path %q: %w", targetRelativePath, err) + } + + backupDir := BackupDir() + if backupDir == "" { + return "", fmt.Errorf("cannot determine backup directory: home directory not found") + } + + _, backupPath, err := allocateBackupPath(backupDir, targetName) + if err != nil { + return "", fmt.Errorf("failed to allocate backup path: %w", err) + } + if err := os.MkdirAll(backupPath, 0o755); err != nil { + return "", fmt.Errorf("failed to create backup directory: %w", err) + } + + manifest := snapshotManifest{ + Version: snapshotManifestVersion, + RestoreBaseMode: opts.RestoreBaseMode, + TargetRelativePath: cleanTargetRelativePath, + Entries: make([]snapshotManifestEntry, 0, len(paths)), + } + + for i, path := range paths { + entry, err := snapshotEntryForPath(backupPath, i, path) + if err != nil { + return "", err + } + manifest.Entries = append(manifest.Entries, entry) + } + + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return "", fmt.Errorf("failed to encode snapshot manifest: %w", err) + } + if err := os.WriteFile(filepath.Join(backupPath, snapshotManifestFilename), data, 0o644); err != nil { + return "", fmt.Errorf("failed to write snapshot manifest: %w", err) + } + + return backupPath, nil +} + +func normalizeSnapshotPaths(paths []SnapshotPath) []SnapshotPath { + byRelative := make(map[string]SnapshotPath, len(paths)) + for _, path := range paths { + relative := cleanSnapshotRelativePath(path.RelativePath) + if relative == "" || filepath.IsAbs(relative) { + continue + } + path.RelativePath = relative + + existing, ok := byRelative[relative] + if !ok { + byRelative[relative] = path + continue + } + if path.FollowTopSymlinks { + existing.FollowTopSymlinks = true + } + if existing.SourcePath == "" { + existing.SourcePath = path.SourcePath + } + byRelative[relative] = existing + } + + normalized := make([]SnapshotPath, 0, len(byRelative)) + for _, path := range byRelative { + normalized = append(normalized, path) + } + sort.Slice(normalized, func(i, j int) bool { + return normalized[i].RelativePath < normalized[j].RelativePath + }) + return normalized +} + +func cleanSnapshotRelativePath(relative string) string { + if relative == "" { + return "." + } + return filepath.Clean(relative) +} + +func snapshotEntryForPath(backupPath string, index int, path SnapshotPath) (snapshotManifestEntry, error) { + info, err := os.Stat(path.SourcePath) + if err != nil { + if os.IsNotExist(err) { + return snapshotManifestEntry{ + RelativePath: path.RelativePath, + Kind: "absent", + }, nil + } + return snapshotManifestEntry{}, fmt.Errorf("failed to inspect snapshot path %s: %w", path.SourcePath, err) + } + + storagePath := filepath.Join("entries", fmt.Sprintf("%03d", index)) + fullStoragePath := filepath.Join(backupPath, storagePath) + if info.IsDir() { + if err := os.MkdirAll(filepath.Dir(fullStoragePath), 0o755); err != nil { + return snapshotManifestEntry{}, fmt.Errorf("failed to prepare snapshot directory: %w", err) + } + var copyErr error + if path.FollowTopSymlinks { + copyErr = copyDirFollowTopSymlinks(path.SourcePath, fullStoragePath) + } else { + copyErr = copyDir(path.SourcePath, fullStoragePath) + } + if copyErr != nil { + return snapshotManifestEntry{}, fmt.Errorf("failed to snapshot directory %s: %w", path.SourcePath, copyErr) + } + return snapshotManifestEntry{ + RelativePath: path.RelativePath, + Kind: "dir", + StoragePath: filepath.ToSlash(storagePath), + }, nil + } + if !info.Mode().IsRegular() { + return snapshotManifestEntry{}, fmt.Errorf("unsupported snapshot path type: %s", path.SourcePath) + } + + if err := os.MkdirAll(filepath.Dir(fullStoragePath), 0o755); err != nil { + return snapshotManifestEntry{}, fmt.Errorf("failed to prepare snapshot file: %w", err) + } + if err := copyFile(path.SourcePath, fullStoragePath); err != nil { + return snapshotManifestEntry{}, fmt.Errorf("failed to snapshot file %s: %w", path.SourcePath, err) + } + return snapshotManifestEntry{ + RelativePath: path.RelativePath, + Kind: "file", + StoragePath: filepath.ToSlash(storagePath), + }, nil +} + +func loadSnapshotManifest(targetBackupPath string) (*snapshotManifest, error) { + manifest, err := loadSnapshotManifestFile(targetBackupPath, snapshotManifestFilename, false) + if err != nil { + return nil, err + } + if manifest != nil { + return manifest, nil + } + return loadSnapshotManifestFile(targetBackupPath, legacySnapshotManifestFilename, true) +} + +func loadSnapshotManifestFile(targetBackupPath, filename string, allowLegacySyncManifest bool) (*snapshotManifest, error) { + data, err := os.ReadFile(filepath.Join(targetBackupPath, filename)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read snapshot manifest: %w", err) + } + + manifest, ok, err := parseSnapshotManifest(data, allowLegacySyncManifest) + if err != nil { + return nil, fmt.Errorf("failed to parse snapshot manifest: %w", err) + } + if !ok { + return nil, nil + } + return manifest, nil +} + +func parseSnapshotManifest(data []byte, allowLegacySyncManifest bool) (*snapshotManifest, bool, error) { + var raw struct { + Version *int `json:"version"` + RestoreBaseMode SnapshotRestoreBaseMode `json:"restore_base_mode"` + TargetRelativePath string `json:"target_relative_path"` + Entries []snapshotManifestEntry `json:"entries"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, false, err + } + + if raw.Version == nil { + if allowLegacySyncManifest && len(raw.Entries) == 0 { + return nil, false, nil + } + return nil, false, fmt.Errorf("missing snapshot manifest version") + } + if *raw.Version != snapshotManifestVersion { + return nil, false, fmt.Errorf("unsupported snapshot manifest version: %d", *raw.Version) + } + if raw.RestoreBaseMode != "" && !raw.RestoreBaseMode.valid() { + return nil, false, fmt.Errorf("unsupported snapshot restore base mode: %s", raw.RestoreBaseMode) + } + if raw.TargetRelativePath != "" { + cleaned, err := validateSnapshotRelativePath(raw.TargetRelativePath) + if err != nil { + return nil, false, fmt.Errorf("invalid snapshot target relative path %q: %w", raw.TargetRelativePath, err) + } + raw.TargetRelativePath = cleaned + } + + return &snapshotManifest{ + Version: *raw.Version, + RestoreBaseMode: raw.RestoreBaseMode, + TargetRelativePath: raw.TargetRelativePath, + Entries: raw.Entries, + }, true, nil +} + +func validateSnapshotRelativePath(relative string) (string, error) { + cleaned := cleanSnapshotRelativePath(relative) + if filepath.IsAbs(cleaned) { + return "", fmt.Errorf("invalid snapshot path %q: absolute paths are not allowed", cleaned) + } + if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("invalid snapshot path %q: path traversal is not allowed", cleaned) + } + return cleaned, nil +} + +func validateSnapshotStoragePath(storagePath string) (string, error) { + cleaned := path.Clean(filepath.ToSlash(strings.TrimSpace(storagePath))) + if cleaned == "." { + return "", fmt.Errorf("invalid snapshot storage path %q: empty paths are not allowed", storagePath) + } + if path.IsAbs(cleaned) { + return "", fmt.Errorf("invalid snapshot storage path %q: absolute paths are not allowed", cleaned) + } + if cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return "", fmt.Errorf("invalid snapshot storage path %q: path traversal is not allowed", cleaned) + } + return cleaned, nil +} + +func resolveSnapshotStoragePath(backupRoot, storagePath string) (string, error) { + cleaned, err := validateSnapshotStoragePath(storagePath) + if err != nil { + return "", err + } + return resolvePathWithinRoot(backupRoot, filepath.FromSlash(cleaned), "snapshot storage path") +} + +func resolvePathWithinRoot(root, relative, label string) (string, error) { + root = filepath.Clean(root) + resolved := filepath.Clean(filepath.Join(root, relative)) + rel, err := filepath.Rel(root, resolved) + if err != nil { + return "", fmt.Errorf("invalid %s %q: %w", label, relative, err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("invalid %s %q: path traversal is not allowed", label, relative) + } + return resolved, nil +} + +func snapshotSkillEntryRelativePath(relative string) bool { + cleaned := cleanSnapshotRelativePath(relative) + return cleaned == "." || strings.EqualFold(filepath.Base(cleaned), "skills") +} + +func (mode SnapshotRestoreBaseMode) valid() bool { + switch mode { + case SnapshotRestoreBaseTarget, SnapshotRestoreBaseParent, SnapshotRestoreBaseGrandparent: + return true + default: + return false + } +} diff --git a/internal/backup/restore.go b/internal/backup/restore.go index 6f56446b..6d8a81c6 100644 --- a/internal/backup/restore.go +++ b/internal/backup/restore.go @@ -4,8 +4,17 @@ import ( "fmt" "os" "path/filepath" + "slices" "sort" + "strings" "time" + + "skillshare/internal/config" +) + +var ( + restoreCopyDir = copyDir + restoreCopyFile = copyFile ) // RestoreOptions holds options for restore operation @@ -15,14 +24,17 @@ type RestoreOptions struct { // ValidateRestore checks if a restore would succeed without modifying the destination. func ValidateRestore(backupPath, targetName, destPath string, opts RestoreOptions) error { - targetBackupPath := filepath.Join(backupPath, targetName) + targetBackupPath, _, err := resolveBackupTargetPath(backupPath, targetName) + if err != nil { + return err + } - // Verify backup source exists - if _, err := os.Stat(targetBackupPath); err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("target '%s' not found in backup", targetName) - } - return fmt.Errorf("cannot access backup: %w", err) + manifest, err := loadSnapshotManifest(targetBackupPath) + if err != nil { + return err + } + if manifest != nil { + return validateSnapshotRestore(targetBackupPath, targetName, manifest, destPath, opts) } // Check if destination exists @@ -59,30 +71,19 @@ func RestoreToPath(backupPath, targetName, destPath string, opts RestoreOptions) return err } - targetBackupPath := filepath.Join(backupPath, targetName) - - // Check if destination exists - info, err := os.Stat(destPath) - if err == nil { - if info.Mode()&os.ModeSymlink != 0 { - // It's a symlink - remove it - if err := os.Remove(destPath); err != nil { - return fmt.Errorf("failed to remove existing symlink: %w", err) - } - } else if info.IsDir() { - // Remove existing directory for clean restore - if err := os.RemoveAll(destPath); err != nil { - return fmt.Errorf("failed to remove existing directory: %w", err) - } - } else { - return fmt.Errorf("destination exists and is not a directory: %s", destPath) - } - } else if !os.IsNotExist(err) { - return fmt.Errorf("cannot access destination: %w", err) + targetBackupPath, _, err := resolveBackupTargetPath(backupPath, targetName) + if err != nil { + return err + } + manifest, err := loadSnapshotManifest(targetBackupPath) + if err != nil { + return err + } + if manifest != nil { + return restoreSnapshot(targetBackupPath, targetName, manifest, destPath) } - // Copy backup to destination - return copyDir(targetBackupPath, destPath) + return restoreDirAtomic(targetBackupPath, destPath) } // RestoreLatest restores the most recent backup for a target from the global backup dir. @@ -100,13 +101,13 @@ func RestoreLatestInDir(backupDir, targetName, destPath string, opts RestoreOpti // Find most recent backup containing the target for _, b := range backups { - for _, t := range b.Targets { - if t == targetName { - if err := RestoreToPath(b.Path, targetName, destPath, opts); err != nil { - return "", err - } - return b.Timestamp, nil + if _, ok, err := config.ResolveTargetNameCandidate(targetName, b.Targets); err != nil { + return "", fmt.Errorf("resolve backup target for %q in %s: %w", targetName, b.Timestamp, err) + } else if ok { + if err := RestoreToPath(b.Path, targetName, destPath, opts); err != nil { + return "", err } + return b.Timestamp, nil } } @@ -127,11 +128,14 @@ func FindBackupsForTargetInDir(backupDir, targetName string) ([]BackupInfo, erro var result []BackupInfo for _, b := range allBackups { - for _, t := range b.Targets { - if t == targetName { - result = append(result, b) - break + if matched, ok, err := config.ResolveTargetNameCandidate(targetName, b.Targets); err != nil { + return nil, fmt.Errorf("resolve backup target for %q in %s: %w", targetName, b.Timestamp, err) + } else if ok { + normalized := b + if matched != targetName && !slices.Contains(normalized.Targets, targetName) { + normalized.Targets = append(append([]string(nil), normalized.Targets...), targetName) } + result = append(result, normalized) } } @@ -161,12 +165,15 @@ func GetBackupByTimestampInDir(backupDir, timestamp string) (*BackupInfo, error) // BackupVersion describes a single timestamped backup for a target. type BackupVersion struct { - Timestamp time.Time - Label string // formatted: "2006-01-02 15:04:05" - Dir string // full path to target dir inside this backup - SkillCount int - TotalSize int64 - SkillNames []string + Timestamp time.Time + Label string // formatted: "2006-01-02 15:04:05" or ".000" when needed + Dir string // full path to target dir inside this backup + SkillBaseDir string + SkillCount int + TotalSize int64 + SkillNames []string + SnapshotPaths []string + Manifest bool } // ListBackupVersions returns all backup versions for a target, newest first. @@ -203,30 +210,46 @@ func listBackupVersions(backupDir, targetName string, computeSize bool) ([]Backu continue } - ts, parseErr := time.ParseInLocation("2006-01-02_15-04-05", entry.Name(), time.Local) + ts, parseErr := parseBackupTimestamp(entry.Name()) if parseErr != nil { continue } - targetDir := filepath.Join(backupDir, entry.Name(), targetName) + targetDir, _, err := resolveBackupTargetPath(filepath.Join(backupDir, entry.Name()), targetName) + if err != nil { + if strings.Contains(err.Error(), "not found in backup") { + continue + } + return nil, fmt.Errorf("resolve backup target for %q in %s: %w", targetName, entry.Name(), err) + } + if targetDir == "" { + continue + } info, statErr := os.Stat(targetDir) if statErr != nil || !info.IsDir() { continue } - // Collect skill subdirectories - skillEntries, readErr := os.ReadDir(targetDir) - if readErr != nil { - continue + skillBaseDir := targetDir + skillNames, snapshotPaths, err := backupVersionContents(targetDir) + if err != nil { + return nil, err } - - var skillNames []string - for _, se := range skillEntries { - if se.IsDir() { - skillNames = append(skillNames, se.Name()) + manifest, err := loadSnapshotManifest(targetDir) + if err != nil { + return nil, err + } + if manifest != nil { + for _, entry := range manifest.Entries { + if entry.Kind == "dir" && snapshotSkillEntryRelativePath(entry.RelativePath) { + skillBaseDir, err = resolveSnapshotStoragePath(targetDir, entry.StoragePath) + if err != nil { + return nil, err + } + break + } } } - sort.Strings(skillNames) var totalSize int64 = -1 if computeSize { @@ -234,12 +257,15 @@ func listBackupVersions(backupDir, targetName string, computeSize bool) ([]Backu } versions = append(versions, BackupVersion{ - Timestamp: ts, - Label: ts.Format("2006-01-02 15:04:05"), - Dir: targetDir, - SkillCount: len(skillNames), - TotalSize: totalSize, - SkillNames: skillNames, + Timestamp: ts, + Label: formatBackupTimestampLabel(ts), + Dir: targetDir, + SkillBaseDir: skillBaseDir, + SkillCount: len(skillNames), + TotalSize: totalSize, + SkillNames: skillNames, + SnapshotPaths: snapshotPaths, + Manifest: manifest != nil, }) } @@ -250,3 +276,440 @@ func listBackupVersions(backupDir, targetName string, computeSize bool) ([]Backu return versions, nil } + +func validateSnapshotRestore(targetBackupPath, targetName string, manifest *snapshotManifest, destPath string, opts RestoreOptions) error { + restoreRoot, err := snapshotValidatedRestoreBasePath(destPath, targetName, manifest) + if err != nil { + return err + } + for _, entry := range manifest.Entries { + resolvedPath, err := resolveSnapshotRestorePath(restoreRoot, entry.RelativePath) + if err != nil { + return err + } + if entry.Kind == "file" || entry.Kind == "dir" { + sourcePath, err := resolveSnapshotStoragePath(targetBackupPath, entry.StoragePath) + if err != nil { + return err + } + if _, err := os.Stat(sourcePath); err != nil { + return fmt.Errorf("cannot access snapshot storage %s: %w", sourcePath, err) + } + } + if err := validateRestorePath(resolvedPath, opts); err != nil { + return err + } + } + return nil +} + +func validateRestorePath(destPath string, opts RestoreOptions) error { + info, err := os.Lstat(destPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("cannot access destination: %w", err) + } + + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + if info.IsDir() { + if !opts.Force { + entries, _ := os.ReadDir(destPath) + if len(entries) > 0 { + return fmt.Errorf("destination is not empty: %s (use --force to overwrite)", destPath) + } + } + return nil + } + if !opts.Force { + return fmt.Errorf("destination exists and is not a directory: %s", destPath) + } + return nil +} + +func restoreSnapshot(targetBackupPath, targetName string, manifest *snapshotManifest, destPath string) error { + restoreRoot, err := snapshotValidatedRestoreBasePath(destPath, targetName, manifest) + if err != nil { + return err + } + for _, entry := range manifest.Entries { + resolvedPath, err := resolveSnapshotRestorePath(restoreRoot, entry.RelativePath) + if err != nil { + return err + } + + switch entry.Kind { + case "absent": + if err := os.RemoveAll(resolvedPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove %s: %w", resolvedPath, err) + } + case "file": + storagePath, err := resolveSnapshotStoragePath(targetBackupPath, entry.StoragePath) + if err != nil { + return err + } + if err := restoreFileAtomic(storagePath, resolvedPath); err != nil { + return fmt.Errorf("failed to restore file %s: %w", resolvedPath, err) + } + case "dir": + storagePath, err := resolveSnapshotStoragePath(targetBackupPath, entry.StoragePath) + if err != nil { + return err + } + if err := restoreDirAtomic(storagePath, resolvedPath); err != nil { + return fmt.Errorf("failed to restore directory %s: %w", resolvedPath, err) + } + default: + return fmt.Errorf("unsupported snapshot entry kind: %s", entry.Kind) + } + } + return nil +} + +func restoreDirAtomic(srcPath, destPath string) error { + return restorePathAtomic(destPath, true, func(stagedPath string) error { + return restoreCopyDir(srcPath, stagedPath) + }) +} + +func restoreFileAtomic(srcPath, destPath string) error { + return restorePathAtomic(destPath, false, func(stagedPath string) error { + return restoreCopyFile(srcPath, stagedPath) + }) +} + +func restorePathAtomic(destPath string, dir bool, populate func(stagedPath string) error) error { + parentDir := filepath.Dir(destPath) + if err := os.MkdirAll(parentDir, 0o755); err != nil { + return fmt.Errorf("failed to create restore directory: %w", err) + } + + stagedPath, cleanup, err := createRestoreStagePath(parentDir, dir) + if err != nil { + return err + } + keepStage := true + defer func() { + if keepStage { + cleanup() + } + }() + + if err := populate(stagedPath); err != nil { + return err + } + if err := replaceWithRestoreStage(destPath, stagedPath); err != nil { + return err + } + keepStage = false + return nil +} + +func createRestoreStagePath(parentDir string, dir bool) (string, func(), error) { + if dir { + stageDir, err := os.MkdirTemp(parentDir, ".skillshare-restore-dir-*") + if err != nil { + return "", nil, fmt.Errorf("failed to create restore staging directory: %w", err) + } + return stageDir, func() { _ = os.RemoveAll(stageDir) }, nil + } + + stageFile, err := os.CreateTemp(parentDir, ".skillshare-restore-file-*") + if err != nil { + return "", nil, fmt.Errorf("failed to create restore staging file: %w", err) + } + stagePath := stageFile.Name() + if err := stageFile.Close(); err != nil { + _ = os.Remove(stagePath) + return "", nil, fmt.Errorf("failed to close restore staging file: %w", err) + } + return stagePath, func() { _ = os.Remove(stagePath) }, nil +} + +func replaceWithRestoreStage(destPath, stagedPath string) error { + previousPath, hadPrevious, err := moveExistingRestorePathAside(destPath) + if err != nil { + return err + } + + if err := os.Rename(stagedPath, destPath); err != nil { + if hadPrevious { + if rollbackErr := os.Rename(previousPath, destPath); rollbackErr != nil { + return fmt.Errorf("failed to replace restore path %s: %w (rollback failed: %v)", destPath, err, rollbackErr) + } + } + return fmt.Errorf("failed to replace restore path %s: %w", destPath, err) + } + + if hadPrevious { + if err := os.RemoveAll(previousPath); err != nil { + return fmt.Errorf("restored %s but failed to remove previous path backup %s: %w", destPath, previousPath, err) + } + } + return nil +} + +func moveExistingRestorePathAside(destPath string) (string, bool, error) { + if _, err := os.Lstat(destPath); err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", false, fmt.Errorf("cannot access destination: %w", err) + } + + previousPath, err := reserveRestoreSiblingPath(filepath.Dir(destPath)) + if err != nil { + return "", false, err + } + if err := os.Rename(destPath, previousPath); err != nil { + return "", false, fmt.Errorf("failed to move existing restore path %s aside: %w", destPath, err) + } + return previousPath, true, nil +} + +func reserveRestoreSiblingPath(parentDir string) (string, error) { + reserved, err := os.CreateTemp(parentDir, ".skillshare-restore-old-*") + if err != nil { + return "", fmt.Errorf("failed to reserve restore backup path: %w", err) + } + reservedPath := reserved.Name() + if err := reserved.Close(); err != nil { + _ = os.Remove(reservedPath) + return "", fmt.Errorf("failed to close restore backup reservation: %w", err) + } + if err := os.Remove(reservedPath); err != nil { + return "", fmt.Errorf("failed to reserve restore backup path: %w", err) + } + return reservedPath, nil +} + +func resolveSnapshotRestorePath(destPath, relative string) (string, error) { + cleaned, err := validateSnapshotRelativePath(relative) + if err != nil { + return "", err + } + return resolvePathWithinRoot(destPath, cleaned, "snapshot path") +} + +func backupVersionContents(targetDir string) ([]string, []string, error) { + manifest, err := loadSnapshotManifest(targetDir) + if err != nil { + return nil, nil, err + } + if manifest == nil { + skillEntries, readErr := os.ReadDir(targetDir) + if readErr != nil { + return nil, nil, nil + } + + var skillNames []string + for _, se := range skillEntries { + if se.IsDir() { + skillNames = append(skillNames, se.Name()) + } + } + sort.Strings(skillNames) + return skillNames, nil, nil + } + + snapshotPaths := make([]string, 0, len(manifest.Entries)) + var skillNames []string + for _, entry := range manifest.Entries { + snapshotPaths = append(snapshotPaths, cleanSnapshotRelativePath(entry.RelativePath)) + if entry.Kind != "dir" || !snapshotSkillEntryRelativePath(entry.RelativePath) { + continue + } + skillDir, err := resolveSnapshotStoragePath(targetDir, entry.StoragePath) + if err != nil { + return nil, nil, err + } + entries, readErr := os.ReadDir(skillDir) + if readErr != nil { + return nil, nil, readErr + } + for _, se := range entries { + if se.IsDir() { + skillNames = append(skillNames, se.Name()) + } + } + } + sort.Strings(skillNames) + sort.Strings(snapshotPaths) + return skillNames, snapshotPaths, nil +} + +func snapshotRestoreBasePath(destPath string, manifest *snapshotManifest) (string, error) { + cleaned := filepath.Clean(destPath) + switch manifest.RestoreBaseMode { + case "": + // Legacy snapshots inferred the restore base from the destination path shape. + case SnapshotRestoreBaseTarget: + return cleaned, nil + case SnapshotRestoreBaseParent: + return filepath.Dir(cleaned), nil + case SnapshotRestoreBaseGrandparent: + return filepath.Dir(filepath.Dir(cleaned)), nil + default: + return "", fmt.Errorf("unsupported snapshot restore base mode: %s", manifest.RestoreBaseMode) + } + + for _, entry := range manifest.Entries { + if cleanSnapshotRelativePath(entry.RelativePath) == "." { + return cleaned, nil + } + } + if strings.EqualFold(filepath.Base(cleaned), "skills") { + return filepath.Dir(cleaned), nil + } + return cleaned, nil +} + +func snapshotValidatedRestoreBasePath(destPath, targetName string, manifest *snapshotManifest) (string, error) { + restoreRoot, err := snapshotRestoreBasePath(destPath, manifest) + if err != nil { + return "", err + } + if err := validateSnapshotRestoreTargetPath(restoreRoot, destPath, targetName, manifest); err != nil { + return "", err + } + return restoreRoot, nil +} + +func validateSnapshotRestoreTargetPath(restoreRoot, destPath, targetName string, manifest *snapshotManifest) error { + expectedRelativePath, err := snapshotTargetRelativePath(targetName, manifest) + if err != nil { + return err + } + + currentRelativePath, err := filepath.Rel(restoreRoot, filepath.Clean(destPath)) + if err != nil { + return fmt.Errorf("resolve restore destination %s: %w", destPath, err) + } + currentRelativePath, err = validateSnapshotRelativePath(currentRelativePath) + if err != nil { + return fmt.Errorf("invalid restore destination %s: %w", destPath, err) + } + + if currentRelativePath != expectedRelativePath { + return fmt.Errorf("snapshot target path mismatch: backup was created for %s, current destination resolves to %s", expectedRelativePath, currentRelativePath) + } + return nil +} + +func snapshotTargetRelativePath(targetName string, manifest *snapshotManifest) (string, error) { + if manifest.TargetRelativePath != "" { + return manifest.TargetRelativePath, nil + } + + inferred, ok, err := legacySnapshotTargetRelativePath(targetName, manifest) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("snapshot target path is ambiguous in legacy manifest; cannot safely restore without target_relative_path") + } + return inferred, nil +} + +func legacySnapshotTargetRelativePath(targetName string, manifest *snapshotManifest) (string, bool, error) { + if manifest.RestoreBaseMode == SnapshotRestoreBaseTarget { + return ".", true, nil + } + + candidates := make(map[string]struct{}, len(manifest.Entries)) + for _, entry := range manifest.Entries { + if !snapshotSkillEntryRelativePath(entry.RelativePath) { + continue + } + cleaned, err := validateSnapshotRelativePath(entry.RelativePath) + if err != nil { + return "", false, err + } + candidates[cleaned] = struct{}{} + } + + switch len(candidates) { + case 0: + return knownGlobalSnapshotTargetRelativePath(targetName, manifest) + case 1: + for candidate := range candidates { + return candidate, true, nil + } + default: + return "", false, fmt.Errorf("snapshot target path is ambiguous in legacy manifest; multiple candidate skill paths found") + } + + return "", false, nil +} + +func knownGlobalSnapshotTargetRelativePath(targetName string, manifest *snapshotManifest) (string, bool, error) { + if manifest.RestoreBaseMode == "" { + return "", false, nil + } + + target, ok := config.LookupGlobalTarget(targetName) + if !ok { + return "", false, nil + } + + cleanTargetPath := filepath.Clean(target.Path) + var restoreRoot string + switch manifest.RestoreBaseMode { + case SnapshotRestoreBaseTarget: + return ".", true, nil + case SnapshotRestoreBaseParent: + restoreRoot = filepath.Dir(cleanTargetPath) + case SnapshotRestoreBaseGrandparent: + restoreRoot = filepath.Dir(filepath.Dir(cleanTargetPath)) + default: + return "", false, nil + } + + relativePath, err := filepath.Rel(restoreRoot, cleanTargetPath) + if err != nil { + return "", false, fmt.Errorf("resolve known target path %s for %s: %w", cleanTargetPath, targetName, err) + } + cleanedRelativePath, err := validateSnapshotRelativePath(relativePath) + if err != nil { + return "", false, fmt.Errorf("invalid known target path %s for %s: %w", cleanTargetPath, targetName, err) + } + return cleanedRelativePath, true, nil +} + +func resolveBackupTargetPath(backupPath, targetName string) (string, string, error) { + candidates, err := backupTargetNames(backupPath) + if err != nil { + return "", "", err + } + + resolvedName, ok, err := config.ResolveTargetNameCandidate(targetName, candidates) + if err != nil { + return "", "", fmt.Errorf("target '%s' is ambiguous in backup: %w", targetName, err) + } + if !ok { + return "", "", fmt.Errorf("target '%s' not found in backup", targetName) + } + + return filepath.Join(backupPath, resolvedName), resolvedName, nil +} + +func backupTargetNames(backupPath string) ([]string, error) { + entries, err := os.ReadDir(backupPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("target backup directory does not exist: %s", backupPath) + } + return nil, fmt.Errorf("cannot access backup: %w", err) + } + + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + names = append(names, entry.Name()) + } + } + return names, nil +} diff --git a/internal/backup/timestamp.go b/internal/backup/timestamp.go new file mode 100644 index 00000000..0174e4d5 --- /dev/null +++ b/internal/backup/timestamp.go @@ -0,0 +1,62 @@ +package backup + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +const ( + backupTimestampLayout = "2006-01-02_15-04-05.000" + legacyBackupTimestampLayout = "2006-01-02_15-04-05" +) + +var backupTimestampLayouts = []string{ + backupTimestampLayout, + legacyBackupTimestampLayout, +} + +// NewTimestamp returns the current backup timestamp with millisecond precision. +func NewTimestamp() string { + return time.Now().Format(backupTimestampLayout) +} + +func allocateBackupPath(backupDir, targetName string) (string, string, error) { + for attempt := 0; attempt < 5; attempt++ { + timestamp := NewTimestamp() + backupPath := filepath.Join(backupDir, timestamp, targetName) + _, err := os.Stat(backupPath) + if err == nil { + time.Sleep(time.Millisecond) + continue + } + if os.IsNotExist(err) { + return timestamp, backupPath, nil + } + return "", "", err + } + return "", "", fmt.Errorf("failed to allocate unique backup path for %s", targetName) +} + +func parseBackupTimestamp(value string) (time.Time, error) { + var lastErr error + for _, layout := range backupTimestampLayouts { + parsed, err := time.ParseInLocation(layout, value, time.Local) + if err == nil { + return parsed, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = fmt.Errorf("unsupported timestamp format") + } + return time.Time{}, lastErr +} + +func formatBackupTimestampLabel(ts time.Time) string { + if ts.Nanosecond() == 0 { + return ts.Format("2006-01-02 15:04:05") + } + return ts.Format("2006-01-02 15:04:05.000") +} diff --git a/internal/config/resources.go b/internal/config/resources.go new file mode 100644 index 00000000..9ad6e66b --- /dev/null +++ b/internal/config/resources.go @@ -0,0 +1,19 @@ +package config + +import "path/filepath" + +// ManagedRulesDir returns the managed rules directory for global or project mode. +func ManagedRulesDir(projectRoot string) string { + if projectRoot == "" { + return filepath.Join(BaseDir(), "rules") + } + return filepath.Join(projectRoot, ".skillshare", "rules") +} + +// ManagedHooksDir returns the managed hooks directory for global or project mode. +func ManagedHooksDir(projectRoot string) string { + if projectRoot == "" { + return filepath.Join(BaseDir(), "hooks") + } + return filepath.Join(projectRoot, ".skillshare", "hooks") +} diff --git a/internal/config/targets.go b/internal/config/targets.go index 883fba57..317533ba 100644 --- a/internal/config/targets.go +++ b/internal/config/targets.go @@ -2,6 +2,7 @@ package config import ( _ "embed" + "fmt" "path/filepath" "sort" "strings" @@ -154,8 +155,52 @@ func LookupProjectTarget(name string) (TargetConfig, bool) { // LookupGlobalTarget returns the known global target config for a name. func LookupGlobalTarget(name string) (TargetConfig, bool) { targets := DefaultTargets() - target, ok := targets[name] - return target, ok + if target, ok := targets[name]; ok { + return target, true + } + + // Fallback: check aliases (backward compat — remove once safe) + specs, err := loadTargetSpecs() + if err != nil { + return TargetConfig{}, false + } + for _, spec := range specs { + for _, alias := range spec.Aliases { + if alias == name && spec.Name != "" && spec.Skills.Global != "" { + return targets[spec.Name], true + } + } + } + return TargetConfig{}, false +} + +// ResolveTargetNameCandidate resolves a user-provided target name against a set +// of candidate names, preferring exact matches and then a single unambiguous +// alias/canonical match within the same target spec. +func ResolveTargetNameCandidate(name string, candidates []string) (string, bool, error) { + return resolveTargetNameCandidate(name, candidates, sameTargetSpecName) +} + +// CanonicalTargetName returns the canonical target name for a known target +// spec. Unknown names are returned unchanged with ok=false. +func CanonicalTargetName(name string) (canonical string, ok bool) { + specs, err := loadTargetSpecs() + if err != nil { + return name, false + } + + for _, spec := range specs { + allNames := make([]string, 0, 1+len(spec.Aliases)) + allNames = append(allNames, spec.Name) + allNames = append(allNames, spec.Aliases...) + for _, candidate := range allNames { + if candidate == name { + return spec.Name, true + } + } + } + + return name, false } // GroupedProjectTarget represents a project target, optionally grouped with @@ -281,6 +326,69 @@ func MatchesTargetName(skillTarget, configTarget string) bool { return false } +func resolveTargetNameCandidate(name string, candidates []string, matchers ...func(string, string) bool) (string, bool, error) { + for _, candidate := range candidates { + if candidate == name { + return candidate, true, nil + } + } + + for _, matcher := range matchers { + matches := make([]string, 0, len(candidates)) + seen := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + if candidate == name || !matcher(name, candidate) { + continue + } + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + matches = append(matches, candidate) + } + + switch len(matches) { + case 0: + continue + case 1: + return matches[0], true, nil + default: + sort.Strings(matches) + return "", false, fmt.Errorf("target %q is ambiguous; matches %s", name, strings.Join(matches, ", ")) + } + } + + return "", false, nil +} + +func sameTargetSpecName(a, b string) bool { + specs, err := loadTargetSpecs() + if err != nil { + return false + } + + for _, spec := range specs { + allNames := make([]string, 0, 1+len(spec.Aliases)) + allNames = append(allNames, spec.Name) + allNames = append(allNames, spec.Aliases...) + hasA := false + hasB := false + for _, name := range allNames { + if name == a { + hasA = true + } + if name == b { + hasB = true + } + } + if hasA && hasB { + return true + } + } + + return false +} + // KnownTargetNames returns all known target names (both global and project). func KnownTargetNames() []string { specs, err := loadTargetSpecs() diff --git a/internal/config/targets_resolve_test.go b/internal/config/targets_resolve_test.go new file mode 100644 index 00000000..d37e238b --- /dev/null +++ b/internal/config/targets_resolve_test.go @@ -0,0 +1,59 @@ +package config + +import ( + "strings" + "testing" +) + +func TestResolveTargetNameCandidate_PrefersExactMatch(t *testing.T) { + got, ok, err := resolveTargetNameCandidate("agents", []string{"universal", "agents"}, func(requested, candidate string) bool { + return sameTargetSpecName(requested, candidate) + }) + if err != nil { + t.Fatalf("resolveTargetNameCandidate(exact) error = %v", err) + } + if !ok { + t.Fatal("resolveTargetNameCandidate(exact) = not found, want found") + } + if got != "agents" { + t.Fatalf("resolveTargetNameCandidate(exact) = %q, want %q", got, "agents") + } +} + +func TestResolveTargetNameCandidate_AliasResolvesCanonicalName(t *testing.T) { + got, ok, err := ResolveTargetNameCandidate("agents", []string{"universal", "codex"}) + if err != nil { + t.Fatalf("ResolveTargetNameCandidate(alias) error = %v", err) + } + if !ok { + t.Fatal("ResolveTargetNameCandidate(alias) = not found, want found") + } + if got != "universal" { + t.Fatalf("ResolveTargetNameCandidate(alias) = %q, want %q", got, "universal") + } +} + +func TestResolveTargetNameCandidate_MissingDoesNotCrossMatchSharedPathTargets(t *testing.T) { + _, ok, err := ResolveTargetNameCandidate("agents", []string{"codex"}) + if err != nil { + t.Fatalf("ResolveTargetNameCandidate(missing) error = %v", err) + } + if ok { + t.Fatal("ResolveTargetNameCandidate(missing) = found, want not found") + } +} + +func TestResolveTargetNameCandidate_AmbiguousMatchFails(t *testing.T) { + _, ok, err := resolveTargetNameCandidate("agents", []string{"universal", "codex"}, func(string, string) bool { + return true + }) + if err == nil { + t.Fatal("resolveTargetNameCandidate(ambiguous) error = nil, want ambiguity error") + } + if ok { + t.Fatal("resolveTargetNameCandidate(ambiguous) = found, want not found") + } + if !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("resolveTargetNameCandidate(ambiguous) error = %v, want ambiguity message", err) + } +} diff --git a/internal/config/targets_test.go b/internal/config/targets_test.go index 2c2c9414..3d20c219 100644 --- a/internal/config/targets_test.go +++ b/internal/config/targets_test.go @@ -213,3 +213,26 @@ func TestProjectTargets_ClaudePath(t *testing.T) { t.Errorf("claude project path = %q, want %q", tc.Path, ".claude/skills") } } + +func TestLookupGlobalTarget_Alias(t *testing.T) { + tc, ok := LookupGlobalTarget("universal") + if !ok { + t.Fatal("LookupGlobalTarget should find canonical name 'universal'") + } + if tc.Path == "" { + t.Error("expected non-empty path for universal") + } + + tcAlias, ok := LookupGlobalTarget("agents") + if !ok { + t.Fatal("LookupGlobalTarget should find alias 'agents'") + } + if tcAlias.Path != tc.Path { + t.Errorf("alias path %q != canonical path %q", tcAlias.Path, tc.Path) + } + + _, ok = LookupGlobalTarget("nonexistent-tool") + if ok { + t.Error("LookupGlobalTarget should not find unknown name") + } +} diff --git a/internal/inspect/fifo_test_other.go b/internal/inspect/fifo_test_other.go new file mode 100644 index 00000000..b15f0257 --- /dev/null +++ b/internal/inspect/fifo_test_other.go @@ -0,0 +1,9 @@ +//go:build !unix + +package inspect + +import "errors" + +func createTestFIFO(path string, mode uint32) error { + return errors.New("fifo creation is not supported on this platform") +} diff --git a/internal/inspect/fifo_test_unix.go b/internal/inspect/fifo_test_unix.go new file mode 100644 index 00000000..824186cd --- /dev/null +++ b/internal/inspect/fifo_test_unix.go @@ -0,0 +1,9 @@ +//go:build unix + +package inspect + +import "golang.org/x/sys/unix" + +func createTestFIFO(path string, mode uint32) error { + return unix.Mkfifo(path, mode) +} diff --git a/internal/inspect/file_open_other.go b/internal/inspect/file_open_other.go new file mode 100644 index 00000000..285b50ec --- /dev/null +++ b/internal/inspect/file_open_other.go @@ -0,0 +1,9 @@ +//go:build !unix + +package inspect + +import "os" + +func openReadOnlyFile(path string) (*os.File, error) { + return os.Open(path) +} diff --git a/internal/inspect/file_open_unix.go b/internal/inspect/file_open_unix.go new file mode 100644 index 00000000..e6c3a3ea --- /dev/null +++ b/internal/inspect/file_open_unix.go @@ -0,0 +1,17 @@ +//go:build unix + +package inspect + +import ( + "os" + + "golang.org/x/sys/unix" +) + +func openReadOnlyFile(path string) (*os.File, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(fd), path), nil +} diff --git a/internal/inspect/hooks.go b/internal/inspect/hooks.go new file mode 100644 index 00000000..c191ecc5 --- /dev/null +++ b/internal/inspect/hooks.go @@ -0,0 +1,539 @@ +package inspect + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +const maxHookConfigSize = 512 * 1024 + +type hookLocation struct { + sourceTool string + scope Scope + path string +} + +func ScanHooks(projectRoot string) ([]HookItem, []string, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, nil, fmt.Errorf("resolve home directory: %w", err) + } + + root := strings.TrimSpace(projectRoot) + if root != "" { + root, err = filepath.Abs(root) + if err != nil { + return nil, nil, fmt.Errorf("resolve project root: %w", err) + } + } + + locations := []hookLocation{ + {sourceTool: "claude", scope: ScopeUser, path: filepath.Join(home, ".claude", "settings.json")}, + {sourceTool: "codex", scope: ScopeUser, path: filepath.Join(home, ".codex", "hooks.json")}, + {sourceTool: "gemini", scope: ScopeUser, path: filepath.Join(home, ".gemini", "settings.json")}, + } + if root != "" { + locations = append(locations, + hookLocation{sourceTool: "claude", scope: ScopeProject, path: filepath.Join(root, ".claude", "settings.json")}, + hookLocation{sourceTool: "codex", scope: ScopeProject, path: filepath.Join(root, ".codex", "hooks.json")}, + hookLocation{sourceTool: "gemini", scope: ScopeProject, path: filepath.Join(root, ".gemini", "settings.json")}, + hookLocation{sourceTool: "claude", scope: ScopeProject, path: filepath.Join(root, ".claude", "settings.local.json")}, + ) + } + + locations = dedupeHookLocations(locations) + + var ( + items []HookItem + warnings []string + ) + + for _, loc := range locations { + locItems, locWarnings := readHookItems(loc.path, loc.sourceTool, loc.scope) + warnings = append(warnings, locWarnings...) + items = append(items, locItems...) + } + + sort.Slice(items, func(i, j int) bool { + if items[i].Path != items[j].Path { + return items[i].Path < items[j].Path + } + if items[i].Event != items[j].Event { + return items[i].Event < items[j].Event + } + if items[i].Matcher != items[j].Matcher { + return items[i].Matcher < items[j].Matcher + } + if items[i].EntryIndex != items[j].EntryIndex { + return items[i].EntryIndex < items[j].EntryIndex + } + return items[i].ActionIndex < items[j].ActionIndex + }) + + return items, dedupeWarnings(warnings), nil +} + +func dedupeHookLocations(locations []hookLocation) []hookLocation { + deduped := make([]hookLocation, 0, len(locations)) + byPath := make(map[string]int, len(locations)) + + for _, loc := range locations { + path := resolvedComparablePath(loc.path) + + if idx, ok := byPath[path]; ok { + existing := deduped[idx] + if existing.scope == ScopeUser && loc.scope == ScopeProject { + deduped[idx] = loc + } + continue + } + + byPath[path] = len(deduped) + deduped = append(deduped, loc) + } + + return deduped +} + +func sameResolvedPath(a, b string) bool { + return resolvedComparablePath(a) == resolvedComparablePath(b) +} + +func resolvedComparablePath(path string) string { + if !filepath.IsAbs(path) { + if absPath, err := filepath.Abs(path); err == nil { + path = absPath + } + } + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + return filepath.Clean(path) +} + +func readHookItems(path, sourceTool string, scope Scope) ([]HookItem, []string) { + data, warn, ok := readValidatedRegularFile(path, "hook config", maxHookConfigSize) + if warn != "" { + return nil, []string{warn} + } + if !ok { + return nil, nil + } + + var root map[string]json.RawMessage + if err := json.Unmarshal(data, &root); err != nil { + return nil, []string{fmt.Sprintf("%s: invalid JSON: %v", path, err)} + } + + rawHooks, ok := root["hooks"] + if !ok { + return nil, nil + } + if isJSONNull(rawHooks) { + return nil, []string{fmt.Sprintf("%s: invalid hooks block: null", path)} + } + if len(rawHooks) == 0 { + return nil, nil + } + + var events map[string]json.RawMessage + if err := json.Unmarshal(rawHooks, &events); err != nil { + return nil, []string{fmt.Sprintf("%s: invalid hooks block: %v", path, err)} + } + + var items []HookItem + var warnings []string + for _, event := range hookEventsForTool(sourceTool) { + rawEvent, ok := events[event] + if !ok { + continue + } + if isJSONNull(rawEvent) { + warnings = append(warnings, fmt.Sprintf("%s: invalid %s hook list: null", path, event)) + continue + } + var entries []json.RawMessage + if err := json.Unmarshal(rawEvent, &entries); err != nil { + warnings = append(warnings, fmt.Sprintf("%s: invalid %s hook list: %v", path, event, err)) + continue + } + for i, rawEntry := range entries { + normalized, entryWarnings := normalizeHookEntry(path, sourceTool, scope, event, i, rawEntry) + warnings = append(warnings, entryWarnings...) + if len(normalized) == 0 { + continue + } + items = append(items, normalized...) + } + } + + return items, warnings +} + +func isJSONNull(raw json.RawMessage) bool { + return strings.TrimSpace(string(raw)) == "null" +} + +func hookEventsForTool(sourceTool string) []string { + switch sourceTool { + case "claude": + return []string{ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PermissionRequest", + "PermissionDenied", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "SubagentStart", + "SubagentStop", + "TaskCreated", + "TaskCompleted", + "Stop", + "StopFailure", + "TeammateIdle", + "InstructionsLoaded", + "ConfigChange", + "CwdChanged", + "FileChanged", + "WorktreeCreate", + "WorktreeRemove", + "PreCompact", + "PostCompact", + "Elicitation", + "ElicitationResult", + "SessionEnd", + } + case "codex": + return []string{ + "SessionStart", + "PreToolUse", + "PostToolUse", + "UserPromptSubmit", + "Stop", + } + case "gemini": + return []string{ + "BeforeTool", + "AfterTool", + "BeforeAgent", + "AfterAgent", + "BeforeModel", + "BeforeToolSelection", + "AfterModel", + "SessionStart", + "SessionEnd", + "Notification", + "PreCompress", + } + default: + return nil + } +} + +func normalizeHookEntry(path, sourceTool string, scope Scope, event string, entryIndex int, rawEntry json.RawMessage) ([]HookItem, []string) { + var entry struct { + Matcher string `json:"matcher"` + Sequential *bool `json:"sequential"` + Hooks []json.RawMessage `json:"hooks"` + } + if err := json.Unmarshal(rawEntry, &entry); err != nil { + return nil, []string{fmt.Sprintf("%s: invalid %s hook entry: %v", path, event, err)} + } + if len(entry.Hooks) == 0 { + return nil, []string{fmt.Sprintf("%s: invalid %s hook entry: missing hooks array", path, event)} + } + matcher := strings.TrimSpace(entry.Matcher) + if sourceTool == "codex" && (event == "UserPromptSubmit" || event == "Stop") { + matcher = "" + } + groupID := stableDiscoveryID("hook_group", sourceTool, string(scope), resolvedComparablePath(path), event, matcher) + collectible, collectReason := hookCollectibility(path, sourceTool) + + var items []HookItem + var warnings []string + for i, rawHook := range entry.Hooks { + hook, warn, ok := normalizeHookAction(path, sourceTool, scope, event, matcher, copyOptionalBool(entry.Sequential), groupID, collectible, collectReason, entryIndex, i, rawHook) + if warn != "" { + warnings = append(warnings, warn) + } + if !ok { + continue + } + items = append(items, hook) + } + return items, warnings +} + +func normalizeHookAction(path, sourceTool string, scope Scope, event, matcher string, sequential *bool, groupID string, collectible bool, collectReason string, entryIndex, actionIndex int, rawHook json.RawMessage) (HookItem, string, bool) { + var action struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Command string `json:"command"` + URL string `json:"url"` + Prompt string `json:"prompt"` + Timeout json.RawMessage `json:"timeout"` + TimeoutSec json.RawMessage `json:"timeoutSec"` + StatusMessage string `json:"statusMessage"` + } + if err := json.Unmarshal(rawHook, &action); err != nil { + return HookItem{}, fmt.Sprintf("%s: invalid %s %s action: %v", path, event, matcher, err), false + } + actionType := strings.TrimSpace(action.Type) + name := strings.TrimSpace(action.Name) + description := strings.TrimSpace(action.Description) + command := strings.TrimSpace(action.Command) + url := strings.TrimSpace(action.URL) + prompt := strings.TrimSpace(action.Prompt) + timeout, timeoutSeconds, timeoutWarn := parseHookTimeout(sourceTool, action.Timeout, action.TimeoutSec) + statusMessage := strings.TrimSpace(action.StatusMessage) + if actionType == "" { + return HookItem{}, fmt.Sprintf("%s: invalid %s %s action: missing type", path, event, matcher), false + } + if timeoutWarn != "" { + return HookItem{}, timeoutWarn, false + } + if sourceTool == "codex" && (event == "UserPromptSubmit" || event == "Stop") { + matcher = "" + } + switch sourceTool { + case "claude": + switch actionType { + case "command": + if command == "" { + return HookItem{}, fmt.Sprintf("%s: invalid %s %s action: missing command", path, event, matcher), false + } + return HookItem{ + SourceTool: sourceTool, + Scope: scope, + Event: event, + Matcher: matcher, + GroupID: groupID, + Collectible: collectible, + CollectReason: collectReason, + Sequential: copyOptionalBool(sequential), + Name: name, + Description: description, + Command: command, + Timeout: timeout, + TimeoutSeconds: timeoutSeconds, + StatusMessage: statusMessage, + EntryIndex: entryIndex, + ActionIndex: actionIndex, + ActionType: actionType, + Path: path, + }, "", true + case "http": + if url == "" { + return HookItem{}, fmt.Sprintf("%s: invalid %s %s action: missing url", path, event, matcher), false + } + return HookItem{ + SourceTool: sourceTool, + Scope: scope, + Event: event, + Matcher: matcher, + GroupID: groupID, + Collectible: collectible, + CollectReason: collectReason, + Sequential: copyOptionalBool(sequential), + Name: name, + Description: description, + URL: url, + Timeout: timeout, + TimeoutSeconds: timeoutSeconds, + StatusMessage: statusMessage, + EntryIndex: entryIndex, + ActionIndex: actionIndex, + ActionType: actionType, + Path: path, + }, "", true + case "prompt", "agent": + if prompt == "" { + return HookItem{}, fmt.Sprintf("%s: invalid %s %s action: missing prompt", path, event, matcher), false + } + return HookItem{ + SourceTool: sourceTool, + Scope: scope, + Event: event, + Matcher: matcher, + GroupID: groupID, + Collectible: collectible, + CollectReason: collectReason, + Sequential: copyOptionalBool(sequential), + Name: name, + Description: description, + Prompt: prompt, + Timeout: timeout, + TimeoutSeconds: timeoutSeconds, + StatusMessage: statusMessage, + EntryIndex: entryIndex, + ActionIndex: actionIndex, + ActionType: actionType, + Path: path, + }, "", true + } + case "codex": + if actionType == "command" { + if command == "" { + return HookItem{}, fmt.Sprintf("%s: invalid %s %s action: missing command", path, event, matcher), false + } + return HookItem{ + SourceTool: sourceTool, + Scope: scope, + Event: event, + Matcher: matcher, + GroupID: groupID, + Collectible: collectible, + CollectReason: collectReason, + Sequential: copyOptionalBool(sequential), + Name: name, + Description: description, + Command: command, + Timeout: timeout, + TimeoutSeconds: timeoutSeconds, + StatusMessage: statusMessage, + EntryIndex: entryIndex, + ActionIndex: actionIndex, + ActionType: actionType, + Path: path, + }, "", true + } + case "gemini": + if actionType == "command" { + if command == "" { + return HookItem{}, fmt.Sprintf("%s: invalid %s %s action: missing command", path, event, matcher), false + } + return HookItem{ + SourceTool: sourceTool, + Scope: scope, + Event: event, + Matcher: matcher, + GroupID: groupID, + Collectible: collectible, + CollectReason: collectReason, + Sequential: copyOptionalBool(sequential), + Name: name, + Description: description, + Command: command, + Timeout: timeout, + TimeoutSeconds: timeoutSeconds, + StatusMessage: statusMessage, + EntryIndex: entryIndex, + ActionIndex: actionIndex, + ActionType: actionType, + Path: path, + }, "", true + } + } + if isKnownHookType(sourceTool, actionType) { + return HookItem{}, fmt.Sprintf("%s: unsupported %s %s hook type %q; only command actions are normalized", path, event, matcher, actionType), false + } + return HookItem{}, fmt.Sprintf("%s: invalid %s %s action: unknown type %q", path, event, matcher, actionType), false +} + +func parseHookTimeout(sourceTool string, timeoutRaw, timeoutSecRaw json.RawMessage) (string, *int, string) { + if sourceTool == "codex" { + if timeoutText, timeoutSeconds, ok := decodeNumericHookTimeoutValue(timeoutSecRaw); ok { + return timeoutText, timeoutSeconds, "" + } + if timeoutText, timeoutSeconds, ok := decodeNumericHookTimeoutValue(timeoutRaw); ok { + return timeoutText, timeoutSeconds, "" + } + if len(timeoutRaw) != 0 || len(timeoutSecRaw) != 0 { + return "", nil, "invalid codex timeout: expected numeric seconds" + } + return "", nil, "" + } + + if timeoutText, timeoutSeconds, ok := decodeHookTimeoutValue(timeoutRaw); ok { + return timeoutText, timeoutSeconds, "" + } + if timeoutText, timeoutSeconds, ok := decodeHookTimeoutValue(timeoutSecRaw); ok { + return timeoutText, timeoutSeconds, "" + } + return "", nil, "" +} + +func decodeNumericHookTimeoutValue(raw json.RawMessage) (string, *int, bool) { + timeoutText, timeoutSeconds, ok := decodeHookTimeoutValue(raw) + if !ok || timeoutSeconds == nil { + return "", nil, false + } + return timeoutText, timeoutSeconds, true +} + +func decodeHookTimeoutValue(raw json.RawMessage) (string, *int, bool) { + if len(raw) == 0 || isJSONNull(raw) { + return "", nil, false + } + + var numeric int + if err := json.Unmarshal(raw, &numeric); err == nil { + text := strconv.Itoa(numeric) + return text, &numeric, true + } + + var text string + if err := json.Unmarshal(raw, &text); err == nil { + text = strings.TrimSpace(text) + if text == "" { + return "", nil, false + } + if numeric, err := strconv.Atoi(text); err == nil { + value := numeric + return text, &value, true + } + return text, nil, true + } + + return "", nil, false +} + +func isKnownHookType(sourceTool, actionType string) bool { + switch sourceTool { + case "claude": + switch actionType { + case "command", "http", "prompt", "agent": + return true + default: + return false + } + case "codex": + switch actionType { + case "command": + return true + default: + return false + } + case "gemini": + return actionType == "command" + default: + return actionType == "command" + } +} + +func hookCollectibility(path, sourceTool string) (bool, string) { + if sourceTool == "claude" && filepath.Base(path) == "settings.local.json" && filepath.Base(filepath.Dir(path)) == ".claude" { + return false, "diagnostics-only: .claude/settings.local.json is not collectible" + } + switch strings.ToLower(strings.TrimSpace(sourceTool)) { + case "claude", "codex", "gemini": + return true, "" + } + return true, "" +} + +func copyOptionalBool(value *bool) *bool { + if value == nil { + return nil + } + copy := *value + return © +} diff --git a/internal/inspect/hooks_test.go b/internal/inspect/hooks_test.go new file mode 100644 index 00000000..56659fac --- /dev/null +++ b/internal/inspect/hooks_test.go @@ -0,0 +1,986 @@ +package inspect + +import ( + "bytes" + "net" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func findHookItem(t *testing.T, items []HookItem, path string) HookItem { + t.Helper() + for _, item := range items { + if item.Path == path { + return item + } + } + t.Fatalf("hook item with path %q not found", path) + return HookItem{} +} + +func TestScanHooks_GlobalAndProjectLocations(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + files := map[string]string{ + filepath.Join(home, ".claude", "settings.json"): `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./scripts/check.sh"}]}]}}`, + filepath.Join(home, ".gemini", "settings.json"): `{"hooks":{"BeforeTool":[{"matcher":"Read","hooks":[{"type":"command","command":"./scripts/gemini.sh"}]}]}}`, + filepath.Join(project, ".claude", "settings.json"): `{"hooks":{"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./project/post.sh"}]}]}}`, + filepath.Join(project, ".claude", "settings.local.json"): `{"hooks":{"PreToolUse":[{"matcher":"Write","hooks":[{"type":"command","command":"./project/local.sh"}]}]}}`, + filepath.Join(project, ".gemini", "settings.json"): `{"hooks":{"BeforeTool":[{"matcher":"Write","hooks":[{"type":"command","command":"./project/lint.sh"}]}]}}`, + } + for path, content := range files { + mustWriteFile(t, path, content) + } + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != len(files) { + t.Fatalf("expected %d items, got %d", len(files), len(items)) + } + + globalClaude := findHookItem(t, items, filepath.Join(home, ".claude", "settings.json")) + if globalClaude.SourceTool != "claude" { + t.Fatalf("sourceTool = %q, want claude", globalClaude.SourceTool) + } + if globalClaude.Scope != ScopeUser { + t.Fatalf("scope = %q, want user", globalClaude.Scope) + } + if globalClaude.Event != "PreToolUse" { + t.Fatalf("event = %q, want PreToolUse", globalClaude.Event) + } + if globalClaude.Matcher != "Bash" { + t.Fatalf("matcher = %q, want Bash", globalClaude.Matcher) + } + if globalClaude.Command != "./scripts/check.sh" { + t.Fatalf("command = %q, want ./scripts/check.sh", globalClaude.Command) + } + if globalClaude.ActionType != "command" { + t.Fatalf("actionType = %q, want command", globalClaude.ActionType) + } + + projectGemini := findHookItem(t, items, filepath.Join(project, ".gemini", "settings.json")) + if projectGemini.SourceTool != "gemini" { + t.Fatalf("sourceTool = %q, want gemini", projectGemini.SourceTool) + } + if projectGemini.Scope != ScopeProject { + t.Fatalf("scope = %q, want project", projectGemini.Scope) + } + if projectGemini.Event != "BeforeTool" { + t.Fatalf("event = %q, want BeforeTool", projectGemini.Event) + } + if projectGemini.Command != "./project/lint.sh" { + t.Fatalf("command = %q, want ./project/lint.sh", projectGemini.Command) + } +} + +func TestScanHooks_IgnoresHomeClaudeSettingsLocal(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(home, ".claude", "settings.local.json"), `{"hooks":{"PreToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"./scripts/check-local.sh"}]}]}}`) + mustWriteFile(t, filepath.Join(project, ".claude", "settings.json"), `{"hooks":{"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./project/post.sh"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item from project config, got %d", len(items)) + } + if items[0].Path != filepath.Join(project, ".claude", "settings.json") { + t.Fatalf("path = %q, want project settings.json", items[0].Path) + } + if items[0].Command != "./project/post.sh" { + t.Fatalf("command = %q, want ./project/post.sh", items[0].Command) + } +} + +func TestScanHooks_HomeRootIncludesProjectClaudeSettingsLocal(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "workspace") + + mustWriteFile(t, filepath.Join(home, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./shared.sh"}]}]}}`) + mustWriteFile(t, filepath.Join(home, ".claude", "settings.local.json"), `{"hooks":{"PreToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"./local.sh"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(home) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 2 { + t.Fatalf("expected 2 items from shared home/project config, got %d", len(items)) + } + got := map[string]HookItem{} + for _, item := range items { + got[item.Path] = item + } + shared := got[filepath.Join(home, ".claude", "settings.json")] + if shared.Command != "./shared.sh" { + t.Fatalf("shared command = %q, want ./shared.sh", shared.Command) + } + if shared.Scope != ScopeProject { + t.Fatalf("shared scope = %q, want project", shared.Scope) + } + local := got[filepath.Join(home, ".claude", "settings.local.json")] + if local.Command != "./local.sh" { + t.Fatalf("local command = %q, want ./local.sh", local.Command) + } + if local.Scope != ScopeProject { + t.Fatalf("local scope = %q, want project", local.Scope) + } +} + +func TestScanHooks_DirectKnownPathFIFOReturnsPromptly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fifo behavior is platform-dependent on windows") + } + + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + fifoPath := filepath.Join(home, ".claude", "settings.json") + if err := os.MkdirAll(filepath.Dir(fifoPath), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", fifoPath, err) + } + if err := createTestFIFO(fifoPath, 0o644); err != nil { + t.Skipf("unable to create fifo: %v", err) + } + + t.Setenv("HOME", home) + + type result struct { + items []HookItem + warnings []string + err error + } + done := make(chan result, 1) + go func() { + items, warnings, err := ScanHooks("") + done <- result{items: items, warnings: warnings, err: err} + }() + + select { + case res := <-done: + if res.err != nil { + t.Fatalf("ScanHooks() error = %v", res.err) + } + if len(res.items) != 0 { + t.Fatalf("expected 0 items, got %d", len(res.items)) + } + if len(res.warnings) == 0 { + t.Fatal("expected warning for fifo hook config") + } + case <-time.After(2 * time.Second): + t.Fatal("ScanHooks() hung on fifo hook config") + } +} + +func TestScanHooks_UnsupportedShapesAreSkippedAndWarningsCollected(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(home, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./scripts/check.sh"}]}]}}`) + mustWriteFile(t, filepath.Join(project, ".gemini", "settings.json"), `{"hooks":{"BeforeTool":[{"matcher":"Write","hooks":[{"type":"unknown","command":"./skip.sh"},{"type":"command"}]},{"matcher":"SkipMe","hooks":"not-an-array"}]}}`) + mustWriteFile(t, filepath.Join(project, ".claude", "settings.local.json"), `not json`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 supported hook item, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warnings for malformed hook config") + } + for _, item := range items { + if item.Command == "./skip.sh" { + t.Fatal("unsupported hook shapes should be skipped") + } + } +} + +func TestScanHooks_MalformedInFileConfigEmitsWarnings(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./scripts/check.sh"}]},{"matcher":"Write","hooks":"not-an-array"},{"matcher":"Edit","hooks":[{"type":"command"}]},{"matcher":"Skip","hooks":[{"type":"unknown","command":"./skip.sh"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 supported hook item, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warnings for malformed in-file hook config") + } + + item := items[0] + if item.Command != "./scripts/check.sh" { + t.Fatalf("command = %q, want ./scripts/check.sh", item.Command) + } + if item.Scope != ScopeProject { + t.Fatalf("scope = %q, want project", item.Scope) + } +} + +func TestScanHooks_MissingHooksArrayEmitsWarning(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash"},{"matcher":"Edit","hooks":[{"type":"command","command":"./scripts/check.sh"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 supported hook item, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for missing hooks array") + } + + item := items[0] + if item.Command != "./scripts/check.sh" { + t.Fatalf("command = %q, want ./scripts/check.sh", item.Command) + } + if item.Scope != ScopeProject { + t.Fatalf("scope = %q, want project", item.Scope) + } +} + +func TestScanHooks_ReadsSymlinkedConfigFile(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + target := filepath.Join(tmp, "outside.json") + mustWriteFile(t, target, `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./leak.sh"}]}]}}`) + + link := filepath.Join(project, ".claude", "settings.json") + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", link, err) + } + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks not supported: %v", err) + } + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + item := items[0] + if item.Path != link { + t.Fatalf("path = %q, want symlink path %q", item.Path, link) + } + if item.Command != "./leak.sh" { + t.Fatalf("command = %q, want ./leak.sh", item.Command) + } + if item.Scope != ScopeProject { + t.Fatalf("scope = %q, want project", item.Scope) + } +} + +func TestScanHooks_SkipsOversizedConfigFile(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + path := filepath.Join(project, ".claude", "settings.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + if err := os.WriteFile(path, bytes.Repeat([]byte("a"), 512*1024+1), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("expected 0 items, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for oversized hook config") + } +} + +func TestScanHooks_SkipsNonRegularConfigFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix domain sockets are not supported on windows") + } + + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + path := filepath.Join(project, ".claude", "settings.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + listener, err := net.Listen("unix", path) + if err != nil { + t.Skipf("unable to create unix socket: %v", err) + } + t.Cleanup(func() { + listener.Close() + _ = os.Remove(path) + }) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("expected 0 items, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for non-regular hook config") + } +} + +func TestScanHooks_DedupesOverlappingHomeAndProjectRoots(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "workspace") + + mustWriteFile(t, filepath.Join(home, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./shared.sh"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(home) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + + item := items[0] + if item.Path != filepath.Join(home, ".claude", "settings.json") { + t.Fatalf("path = %q, want shared path", item.Path) + } + if item.Scope != ScopeProject { + t.Fatalf("scope = %q, want project scope to win", item.Scope) + } +} + +func TestScanHooks_UnknownEventNamesAreIgnored(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./ok.sh"}]}],"BogusEvent":[{"matcher":"Bash","hooks":[{"type":"command","command":"./skip.sh"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if items[0].Event != "PreToolUse" { + t.Fatalf("event = %q, want PreToolUse", items[0].Event) + } + if items[0].Command != "./ok.sh" { + t.Fatalf("command = %q, want ./ok.sh", items[0].Command) + } +} + +func TestScanHooks_ClaudeSupportedHandlerTypesProduceItems(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"http","url":"https://example.com/hook"},{"type":"prompt","prompt":"Evaluate the input"},{"type":"agent","prompt":"Verify the input"},{"type":"command","command":"./ok.sh"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 4 { + t.Fatalf("expected 4 hook items, got %d", len(items)) + } + got := map[string]HookItem{} + for _, item := range items { + got[item.ActionType] = item + if item.Event != "PreToolUse" { + t.Fatalf("event = %q, want PreToolUse", item.Event) + } + if item.Scope != ScopeProject { + t.Fatalf("scope = %q, want project", item.Scope) + } + } + if got["command"].Command != "./ok.sh" { + t.Fatalf("command = %q, want ./ok.sh", got["command"].Command) + } + if got["http"].ActionType != "http" { + t.Fatalf("http actionType = %q, want http", got["http"].ActionType) + } + if got["http"].URL != "https://example.com/hook" { + t.Fatalf("http url = %q, want https://example.com/hook", got["http"].URL) + } + if got["prompt"].ActionType != "prompt" { + t.Fatalf("prompt actionType = %q, want prompt", got["prompt"].ActionType) + } + if got["prompt"].Prompt != "Evaluate the input" { + t.Fatalf("prompt payload = %q, want Evaluate the input", got["prompt"].Prompt) + } + if got["agent"].ActionType != "agent" { + t.Fatalf("agent actionType = %q, want agent", got["agent"].ActionType) + } + if got["agent"].Prompt != "Verify the input" { + t.Fatalf("agent payload = %q, want Verify the input", got["agent"].Prompt) + } +} + +func TestScanHooks_ClaudeUnknownHandlerTypeEmitsWarning(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"commmand","command":"./bad.sh"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("expected 0 items, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for unknown Claude handler type") + } +} + +func TestScanHooks_GeminiUnknownHandlerTypeEmitsWarning(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".gemini", "settings.json"), `{"hooks":{"BeforeTool":[{"matcher":"Read","hooks":[{"type":"commmand","command":"./bad.sh"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("expected 0 items, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for unknown Gemini handler type") + } +} + +func TestScanHooks_GeminiHttpHandlerEmitsInvalidWarning(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".gemini", "settings.json"), `{"hooks":{"BeforeTool":[{"matcher":"Read","hooks":[{"type":"http","url":"https://example.com/hook"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("expected 0 items, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for Gemini http handler type") + } + if !strings.Contains(warnings[0], "invalid") && !strings.Contains(warnings[0], "unknown type") { + t.Fatalf("warning = %q, want invalid/unknown type warning", warnings[0]) + } +} + +func TestScanHooks_NullHooksBlockEmitsWarning(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".claude", "settings.json"), `{"hooks":null}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("expected 0 items, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for null hooks block") + } +} + +func TestScanHooks_NullHookEventEmitsWarning(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".claude", "settings.json"), `{"hooks":{"PreToolUse":null}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("expected 0 items, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for null hook event") + } +} + +func TestScanHooks_ClaudeDocumentedEventIsRecognized(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".claude", "settings.json"), `{"hooks":{"UserPromptSubmit":[{"matcher":"Bash","hooks":[{"type":"command","command":"./submit.sh"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if items[0].Event != "UserPromptSubmit" { + t.Fatalf("event = %q, want UserPromptSubmit", items[0].Event) + } + if items[0].Command != "./submit.sh" { + t.Fatalf("command = %q, want ./submit.sh", items[0].Command) + } +} + +func TestScanHooks_GeminiDocumentedEventIsRecognized(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".gemini", "settings.json"), `{"hooks":{"BeforeAgent":[{"matcher":"Read","hooks":[{"type":"command","command":"./agent.sh"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if items[0].Event != "BeforeAgent" { + t.Fatalf("event = %q, want BeforeAgent", items[0].Event) + } + if items[0].Command != "./agent.sh" { + t.Fatalf("command = %q, want ./agent.sh", items[0].Command) + } +} + +func TestScanHooks_OverlappingRootPreservesMultipleHooksPerFile(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "workspace") + + mustWriteFile(t, filepath.Join(home, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./pre.sh"}]}],"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./post.sh"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(home) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + + got := map[string]HookItem{} + for _, item := range items { + got[item.Event] = item + if item.Scope != ScopeProject { + t.Fatalf("scope for %s = %q, want project", item.Event, item.Scope) + } + if item.Path != filepath.Join(home, ".claude", "settings.json") { + t.Fatalf("path for %s = %q, want shared path", item.Event, item.Path) + } + } + if got["PreToolUse"].Command != "./pre.sh" { + t.Fatalf("pre command = %q, want ./pre.sh", got["PreToolUse"].Command) + } + if got["PostToolUse"].Command != "./post.sh" { + t.Fatalf("post command = %q, want ./post.sh", got["PostToolUse"].Command) + } +} + +func TestScanHooks_CodexAndPrivateLocal(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + userCodexPath := filepath.Join(home, ".codex", "hooks.json") + projectCodexPath := filepath.Join(project, ".codex", "hooks.json") + projectClaudeLocalPath := filepath.Join(project, ".claude", "settings.local.json") + + mustWriteFile(t, userCodexPath, `{"hooks":{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"./user-codex.sh"}]}]}}`) + mustWriteFile(t, projectCodexPath, `{"hooks":{"PostToolUse":[{"matcher":"Write","hooks":[{"type":"command","command":"./project-codex.sh"}]}]}}`) + mustWriteFile(t, projectClaudeLocalPath, `{"hooks":{"PreToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"./project-local.sh"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + + var userCodex, projectCodex, projectLocal HookItem + for _, item := range items { + switch item.Path { + case userCodexPath: + userCodex = item + case projectCodexPath: + projectCodex = item + case projectClaudeLocalPath: + projectLocal = item + } + } + + if userCodex.Path == "" { + t.Fatal("expected user codex hook item") + } + if userCodex.SourceTool != "codex" { + t.Fatalf("sourceTool = %q, want codex", userCodex.SourceTool) + } + if userCodex.Scope != ScopeUser { + t.Fatalf("scope = %q, want user", userCodex.Scope) + } + if userCodex.Command != "./user-codex.sh" { + t.Fatalf("command = %q, want ./user-codex.sh", userCodex.Command) + } + if strings.TrimSpace(userCodex.GroupID) == "" { + t.Fatal("expected non-empty groupID for user codex hook") + } + if !userCodex.Collectible { + t.Fatal("expected user codex hook to be collectible") + } + if userCodex.CollectReason != "" { + t.Fatalf("collectReason = %q, want empty", userCodex.CollectReason) + } + + if projectCodex.Path == "" { + t.Fatal("expected project codex hook item") + } + if projectCodex.SourceTool != "codex" { + t.Fatalf("sourceTool = %q, want codex", projectCodex.SourceTool) + } + if projectCodex.Scope != ScopeProject { + t.Fatalf("scope = %q, want project", projectCodex.Scope) + } + if projectCodex.Command != "./project-codex.sh" { + t.Fatalf("command = %q, want ./project-codex.sh", projectCodex.Command) + } + if strings.TrimSpace(projectCodex.GroupID) == "" { + t.Fatal("expected non-empty groupID for project codex hook") + } + if !projectCodex.Collectible { + t.Fatal("expected project codex hook to be collectible") + } + if projectCodex.CollectReason != "" { + t.Fatalf("collectReason = %q, want empty", projectCodex.CollectReason) + } + + if projectLocal.Path == "" { + t.Fatal("expected project local claude hook item") + } + if projectLocal.SourceTool != "claude" { + t.Fatalf("sourceTool = %q, want claude", projectLocal.SourceTool) + } + if projectLocal.Scope != ScopeProject { + t.Fatalf("scope = %q, want project", projectLocal.Scope) + } + if projectLocal.Command != "./project-local.sh" { + t.Fatalf("command = %q, want ./project-local.sh", projectLocal.Command) + } + if strings.TrimSpace(projectLocal.GroupID) == "" { + t.Fatal("expected non-empty groupID for local hook") + } + if projectLocal.Collectible { + t.Fatal("expected project local claude hook to be non-collectible") + } + if !strings.Contains(strings.ToLower(projectLocal.CollectReason), "local") { + t.Fatalf("collectReason = %q, want reason mentioning local/private scope", projectLocal.CollectReason) + } +} + +func TestScanHooks_CodexUnsupportedEventIgnored(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".codex", "hooks.json"), `{"hooks":{"FileChanged":[{"matcher":"Write","hooks":[{"type":"command","command":"./file-changed.sh"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings for unsupported codex event, got %v", warnings) + } + if len(items) != 0 { + t.Fatalf("expected unsupported codex event to be ignored, got %d items", len(items)) + } +} + +func TestScanHooks_CodexUnsupportedActionTypeWarned(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".codex", "hooks.json"), `{"hooks":{"PreToolUse":[{"matcher":"Write","hooks":[{"type":"http","url":"https://example.com/hook"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("expected unsupported codex action type to be skipped, got %d items", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for unsupported codex action type") + } +} + +func TestScanHooks_CodexEmptyMatcherAndNumericTimeout(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".codex", "hooks.json"), `{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"./submit.sh","timeout":30}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"./stop.sh","timeoutSec":45}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 2 { + t.Fatalf("expected 2 codex hook items, got %d", len(items)) + } + + got := map[string]HookItem{} + for _, item := range items { + got[item.Event] = item + if item.Matcher != "" { + t.Fatalf("matcher = %q, want empty for codex event %s", item.Matcher, item.Event) + } + if item.TimeoutSeconds == nil { + t.Fatalf("timeoutSeconds = nil for event %s", item.Event) + } + } + if got["UserPromptSubmit"].TimeoutSeconds == nil || *got["UserPromptSubmit"].TimeoutSeconds != 30 { + t.Fatalf("UserPromptSubmit timeoutSeconds = %#v, want 30", got["UserPromptSubmit"].TimeoutSeconds) + } + if got["Stop"].TimeoutSeconds == nil || *got["Stop"].TimeoutSeconds != 45 { + t.Fatalf("Stop timeoutSeconds = %#v, want 45", got["Stop"].TimeoutSeconds) + } +} + +func TestScanHooks_CodexRejectsNonNumericTimeoutString(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".codex", "hooks.json"), `{"hooks":{"PreToolUse":[{"matcher":"Write","hooks":[{"type":"command","command":"./check.sh","timeout":"30s"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("expected invalid codex timeout to be skipped, got %d items", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for invalid codex timeout string") + } +} + +func TestScanHooks_CodexPrefersNumericTimeoutSecOverInvalidTimeout(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".codex", "hooks.json"), `{"hooks":{"PreToolUse":[{"matcher":"Write","hooks":[{"type":"command","command":"./check.sh","timeout":"30s","timeoutSec":30}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 codex hook item, got %d", len(items)) + } + if items[0].TimeoutSeconds == nil || *items[0].TimeoutSeconds != 30 { + t.Fatalf("timeoutSeconds = %#v, want 30", items[0].TimeoutSeconds) + } +} + +func TestScanHooks_GeminiHooksAreCollectibleAndPreserveMetadata(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".gemini", "settings.json"), `{"hooks":{"BeforeTool":[{"matcher":"Read","sequential":true,"hooks":[{"type":"command","name":"lint-read","description":"Run read lint","command":"./gemini.sh","timeout":30000}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 gemini hook item, got %d", len(items)) + } + if !items[0].Collectible { + t.Fatal("expected gemini hook to be collectible") + } + if items[0].CollectReason != "" { + t.Fatalf("collectReason = %q, want empty", items[0].CollectReason) + } + if items[0].Sequential == nil || !*items[0].Sequential { + t.Fatalf("sequential = %#v, want true", items[0].Sequential) + } + if items[0].Name != "lint-read" { + t.Fatalf("name = %q, want lint-read", items[0].Name) + } + if items[0].Description != "Run read lint" { + t.Fatalf("description = %q, want Run read lint", items[0].Description) + } + if items[0].Timeout != "30000" { + t.Fatalf("timeout = %q, want 30000", items[0].Timeout) + } +} + +func TestScanHooks_PreservesHookMetadata(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".claude", "settings.json"), `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./check.sh","timeout":"30s","statusMessage":"Running check"}]}]}}`) + + t.Setenv("HOME", home) + + items, warnings, err := ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if items[0].Timeout != "30s" { + t.Fatalf("timeout = %q, want 30s", items[0].Timeout) + } + if items[0].StatusMessage != "Running check" { + t.Fatalf("statusMessage = %q, want Running check", items[0].StatusMessage) + } +} diff --git a/internal/inspect/rules.go b/internal/inspect/rules.go new file mode 100644 index 00000000..28e0e280 --- /dev/null +++ b/internal/inspect/rules.go @@ -0,0 +1,412 @@ +package inspect + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" + managedpi "skillshare/internal/resources/managed/pi" +) + +const maxRuleFileSize = 512 * 1024 + +type ruleLocation struct { + sourceTool string + scope Scope + path string + walk bool +} + +func ScanRules(projectRoot string) ([]RuleItem, []string, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, nil, fmt.Errorf("resolve home directory: %w", err) + } + + root := strings.TrimSpace(projectRoot) + if root != "" { + root, err = filepath.Abs(root) + if err != nil { + return nil, nil, fmt.Errorf("resolve project root: %w", err) + } + } + overlapHomeProject := root != "" && sameResolvedPath(home, root) + + var locations []ruleLocation + if !overlapHomeProject { + for _, piPath := range managedpi.DiscoveryGlobalPaths(home) { + locations = append(locations, ruleLocation{sourceTool: "pi", scope: ScopeUser, path: piPath}) + } + locations = append(locations, + ruleLocation{sourceTool: "claude", scope: ScopeUser, path: filepath.Join(home, ".claude", "CLAUDE.md")}, + ruleLocation{sourceTool: "codex", scope: ScopeUser, path: filepath.Join(home, ".codex", "AGENTS.md")}, + ruleLocation{sourceTool: "gemini", scope: ScopeUser, path: filepath.Join(home, ".gemini", "GEMINI.md")}, + ruleLocation{sourceTool: "claude", scope: ScopeUser, path: filepath.Join(home, ".claude", "rules"), walk: true}, + ) + } + + if root != "" { + for _, piPath := range managedpi.DiscoveryProjectPaths(root) { + locations = append(locations, ruleLocation{sourceTool: "pi", scope: ScopeProject, path: piPath}) + } + locations = append(locations, + ruleLocation{sourceTool: "claude", scope: ScopeProject, path: filepath.Join(root, "CLAUDE.md")}, + ruleLocation{sourceTool: "codex", scope: ScopeProject, path: filepath.Join(root, "AGENTS.md")}, + ruleLocation{sourceTool: "gemini", scope: ScopeProject, path: filepath.Join(root, "GEMINI.md")}, + ruleLocation{sourceTool: "claude", scope: ScopeProject, path: filepath.Join(root, ".claude", "CLAUDE.md")}, + ruleLocation{sourceTool: "codex", scope: ScopeProject, path: filepath.Join(root, ".codex", "AGENTS.md")}, + ruleLocation{sourceTool: "gemini", scope: ScopeProject, path: filepath.Join(root, ".gemini", "GEMINI.md")}, + ruleLocation{sourceTool: "claude", scope: ScopeProject, path: filepath.Join(root, ".claude", "rules"), walk: true}, + ruleLocation{sourceTool: "gemini", scope: ScopeProject, path: filepath.Join(root, ".gemini", "rules"), walk: true}, + ) + } + + var ( + items []RuleItem + warnings []string + ) + + for _, loc := range locations { + if loc.walk { + files := collectRegularFiles(loc.path, &warnings) + for _, file := range files { + item, warn, ok := readRuleItem(file, loc.sourceTool, loc.scope) + if warn != "" { + warnings = append(warnings, warn) + } + if !ok { + continue + } + items = append(items, item) + } + continue + } + + item, warn, ok := readRuleItem(loc.path, loc.sourceTool, loc.scope) + if warn != "" { + warnings = append(warnings, warn) + } + if !ok { + continue + } + items = append(items, item) + } + + items = dedupeRuleItems(items) + + sort.Slice(items, func(i, j int) bool { + if items[i].Path != items[j].Path { + return items[i].Path < items[j].Path + } + if items[i].SourceTool != items[j].SourceTool { + return items[i].SourceTool < items[j].SourceTool + } + return items[i].Name < items[j].Name + }) + + return items, dedupeWarnings(warnings), nil +} + +func dedupeRuleItems(items []RuleItem) []RuleItem { + deduped := make([]RuleItem, 0, len(items)) + byPath := make(map[string]int, len(items)) + + for _, item := range items { + path := item.Path + if !filepath.IsAbs(path) { + if absPath, err := filepath.Abs(path); err == nil { + path = absPath + item.Path = absPath + } + } + + if idx, ok := byPath[path]; ok { + existing := deduped[idx] + if existing.Scope == ScopeUser && item.Scope == ScopeProject { + deduped[idx] = item + } + continue + } + + byPath[path] = len(deduped) + deduped = append(deduped, item) + } + + return deduped +} + +func readRuleItem(path, sourceTool string, scope Scope) (RuleItem, string, bool) { + data, warn, ok := readValidatedRegularFile(path, "rule file", maxRuleFileSize) + if warn != "" { + return RuleItem{}, warn, false + } + if !ok { + return RuleItem{}, "", false + } + if !isLikelyTextRuleContent(data) { + return RuleItem{}, fmt.Sprintf("%s: skipped non-text rule file", path), false + } + + item := RuleItem{ + Name: filepath.Base(path), + ID: stableDiscoveryID("rule", sourceTool, string(scope), resolvedComparablePath(path)), + SourceTool: sourceTool, + Scope: scope, + Path: path, + Exists: true, + Collectible: true, + Content: string(data), + Size: int64(len(data)), + } + + scopedPaths, warn := parseRuleFrontmatter(path, data) + item.ScopedPaths = scopedPaths + item.IsScoped = len(scopedPaths) > 0 + if warn != "" { + return item, warn, true + } + + return item, "", true +} + +func readValidatedRegularFile(path, kind string, maxSize int64) ([]byte, string, bool) { + file, err := openReadOnlyFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, "", false + } + return nil, fmt.Sprintf("%s: %v", path, err), false + } + defer file.Close() + + stat, err := file.Stat() + if err != nil { + return nil, fmt.Sprintf("%s: %v", path, err), false + } + if !stat.Mode().IsRegular() { + return nil, fmt.Sprintf("%s: skipped non-regular %s", path, kind), false + } + if stat.Size() > maxSize { + return nil, fmt.Sprintf("%s: skipped oversized %s (%d bytes)", path, kind, stat.Size()), false + } + + limited := io.LimitedReader{R: file, N: maxSize + 1} + data, err := io.ReadAll(&limited) + if err != nil { + return nil, fmt.Sprintf("%s: %v", path, err), false + } + if int64(len(data)) > maxSize { + return nil, fmt.Sprintf("%s: skipped oversized %s (%d bytes)", path, kind, len(data)), false + } + return data, "", true +} + +func isLikelyTextRuleContent(data []byte) bool { + if len(data) == 0 { + return true + } + if bytes.Contains(data, []byte{0x00}) { + return false + } + if hasBinaryMagicPrefix(data) { + return false + } + + sample := data + if len(sample) > 1024 { + sample = sample[:1024] + } + + var suspicious int + for _, b := range sample { + switch { + case b == '\n', b == '\r', b == '\t': + case b >= 0x20 && b != 0x7f: + default: + suspicious++ + } + } + + return suspicious*8 <= len(sample) +} + +func hasBinaryMagicPrefix(data []byte) bool { + signatures := [][]byte{ + []byte("%PDF-"), + []byte("\x7fELF"), + []byte("PK\x03\x04"), + []byte("\x89PNG"), + []byte("GIF87a"), + []byte("GIF89a"), + []byte("\xff\xd8\xff"), + []byte("\x1f\x8b"), + } + for _, signature := range signatures { + if bytes.HasPrefix(data, signature) { + return true + } + } + return false +} + +func parseRuleFrontmatter(path string, data []byte) ([]string, string) { + raw, ok, hasFrontmatter := extractFrontmatterRaw(string(data)) + if !hasFrontmatter { + return nil, "" + } + if !ok { + return nil, fmt.Sprintf("%s: invalid frontmatter: missing closing delimiter", path) + } + + var fm map[string]any + if err := yaml.Unmarshal([]byte(raw), &fm); err != nil { + return nil, fmt.Sprintf("%s: invalid frontmatter: %v", path, err) + } + + val, ok := fm["paths"] + if !ok || val == nil { + return nil, "" + } + + switch v := val.(type) { + case []any: + if len(v) == 0 { + return nil, "" + } + paths := make([]string, 0, len(v)) + for _, item := range v { + s, ok := item.(string) + if !ok || strings.TrimSpace(s) == "" { + return nil, fmt.Sprintf("%s: invalid paths frontmatter: expected string list", path) + } + paths = append(paths, s) + } + return paths, "" + case string: + if strings.TrimSpace(v) == "" { + return nil, "" + } + return []string{v}, "" + default: + return nil, fmt.Sprintf("%s: unsupported paths frontmatter type %T", path, val) + } +} + +func extractFrontmatterRaw(content string) (string, bool, bool) { + contentLines := strings.Split(content, "\n") + var frontmatterLines []string + inFrontmatter := false + sawOpener := false + + for _, rawLine := range contentLines { + line := strings.TrimSuffix(rawLine, "\r") + if !sawOpener { + line = strings.TrimPrefix(line, "\ufeff") + if strings.TrimSpace(line) == "" { + continue + } + if line != "---" { + return "", false, false + } + sawOpener = true + inFrontmatter = true + continue + } + + if inFrontmatter && line == "---" { + return strings.Join(frontmatterLines, "\n"), true, true + } + frontmatterLines = append(frontmatterLines, line) + } + + if sawOpener { + return "", false, true + } + return "", false, false +} + +func collectRegularFiles(root string, warnings *[]string) []string { + info, err := os.Stat(root) + if err != nil { + if !os.IsNotExist(err) { + *warnings = append(*warnings, fmt.Sprintf("%s: %v", root, err)) + } + return nil + } + if !info.IsDir() { + return nil + } + + var files []string + visitedDirs := make(map[string]struct{}) + if canonical, err := filepath.EvalSymlinks(root); err == nil { + visitedDirs[canonical] = struct{}{} + } + + var walk func(string) + walk = func(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + *warnings = append(*warnings, fmt.Sprintf("%s: %v", dir, err)) + return + } + for _, entry := range entries { + path := filepath.Join(dir, entry.Name()) + info, err := os.Stat(path) + if err != nil { + *warnings = append(*warnings, fmt.Sprintf("%s: %v", path, err)) + continue + } + if info.IsDir() { + canonical, err := filepath.EvalSymlinks(path) + if err != nil { + *warnings = append(*warnings, fmt.Sprintf("%s: %v", path, err)) + continue + } + if _, ok := visitedDirs[canonical]; ok { + continue + } + visitedDirs[canonical] = struct{}{} + walk(path) + continue + } + if !info.Mode().IsRegular() { + *warnings = append(*warnings, fmt.Sprintf("%s: skipped non-regular rule file", path)) + continue + } + files = append(files, path) + } + } + + walk(root) + sort.Strings(files) + return files +} + +func dedupeWarnings(warnings []string) []string { + if len(warnings) == 0 { + return nil + } + seen := make(map[string]struct{}, len(warnings)) + result := make([]string, 0, len(warnings)) + for _, warning := range warnings { + if _, ok := seen[warning]; ok { + continue + } + seen[warning] = struct{}{} + result = append(result, warning) + } + return result +} + +func stableDiscoveryID(prefix string, parts ...string) string { + sum := sha256.Sum256([]byte(prefix + "\x1f" + strings.Join(parts, "\x1f"))) + return prefix + "_" + hex.EncodeToString(sum[:8]) +} diff --git a/internal/inspect/rules_test.go b/internal/inspect/rules_test.go new file mode 100644 index 00000000..8f6f837c --- /dev/null +++ b/internal/inspect/rules_test.go @@ -0,0 +1,902 @@ +package inspect + +import ( + "bytes" + "net" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func mustWriteFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func findRuleItem(t *testing.T, items []RuleItem, pathSuffix string) RuleItem { + t.Helper() + for _, item := range items { + if strings.HasSuffix(item.Path, pathSuffix) { + return item + } + } + t.Fatalf("rule item with path suffix %q not found", pathSuffix) + return RuleItem{} +} + +func TestScanRules_GlobalAndProjectLocations(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + files := map[string]string{ + filepath.Join(home, ".claude", "CLAUDE.md"): "# Global Claude", + filepath.Join(home, ".codex", "AGENTS.md"): "# Global Codex", + filepath.Join(home, ".gemini", "GEMINI.md"): "# Global Gemini", + filepath.Join(home, ".claude", "rules", "global.md"): "# Global Rule", + filepath.Join(project, "CLAUDE.md"): "# Project Claude", + filepath.Join(project, "AGENTS.md"): "# Project Codex", + filepath.Join(project, "GEMINI.md"): "# Project Gemini", + filepath.Join(project, ".claude", "CLAUDE.md"): "# Project Claude Hidden", + filepath.Join(project, ".codex", "AGENTS.md"): "# Project Codex Hidden", + filepath.Join(project, ".gemini", "GEMINI.md"): "# Project Gemini Hidden", + filepath.Join(project, ".claude", "rules", "backend.md"): "---\npaths:\n - src/**\n - lib/**\n---\n# Backend", + filepath.Join(project, ".gemini", "rules", "frontend.md"): "# Frontend Rule", + } + for path, content := range files { + mustWriteFile(t, path, content) + } + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != len(files) { + t.Fatalf("expected %d items, got %d", len(files), len(items)) + } + + scoped := findRuleItem(t, items, filepath.Join(".claude", "rules", "backend.md")) + if !scoped.IsScoped { + t.Fatal("expected backend rule to be scoped") + } + wantPaths := []string{"src/**", "lib/**"} + if len(scoped.ScopedPaths) != len(wantPaths) { + t.Fatalf("scoped paths = %v, want %v", scoped.ScopedPaths, wantPaths) + } + for i, want := range wantPaths { + if scoped.ScopedPaths[i] != want { + t.Fatalf("scoped path[%d] = %q, want %q", i, scoped.ScopedPaths[i], want) + } + } + if scoped.SourceTool != "claude" { + t.Fatalf("sourceTool = %q, want claude", scoped.SourceTool) + } + if scoped.Scope != ScopeProject { + t.Fatalf("scope = %q, want project", scoped.Scope) + } + if scoped.Path != filepath.Join(project, ".claude", "rules", "backend.md") { + t.Fatalf("path = %q, want project backend path", scoped.Path) + } + if !scoped.Exists { + t.Fatal("expected scoped rule to exist") + } + if scoped.Size == 0 { + t.Fatal("expected scoped rule size to be > 0") + } +} + +func TestScanRules_IncludesPiInstructionFiles(t *testing.T) { + project := t.TempDir() + mustWriteFile(t, filepath.Join(project, ".pi", "SYSTEM.md"), "# System") + mustWriteFile(t, filepath.Join(project, ".pi", "APPEND_SYSTEM.md"), "# Append") + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("ScanRules() warnings = %v", warnings) + } + + var foundSystem, foundAppend bool + for _, item := range items { + if item.SourceTool == "pi" && item.Path == filepath.Join(project, ".pi", "SYSTEM.md") { + foundSystem = true + } + if item.SourceTool == "pi" && item.Path == filepath.Join(project, ".pi", "APPEND_SYSTEM.md") { + foundAppend = true + } + } + if !foundSystem || !foundAppend { + t.Fatalf("items = %#v, want pi SYSTEM and APPEND_SYSTEM entries", items) + } +} + +func TestScanRules_IncludesGlobalPiInstructionFiles(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + + mustWriteFile(t, filepath.Join(home, ".pi", "agent", "AGENTS.md"), "# Global Pi Agents") + mustWriteFile(t, filepath.Join(home, ".pi", "agent", "SYSTEM.md"), "# Global Pi System") + mustWriteFile(t, filepath.Join(home, ".pi", "agent", "APPEND_SYSTEM.md"), "# Global Pi Append") + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules("") + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("ScanRules() warnings = %v", warnings) + } + + agents := findRuleItem(t, items, filepath.Join(".pi", "agent", "AGENTS.md")) + if agents.SourceTool != "pi" { + t.Fatalf("global pi agents sourceTool = %q, want pi", agents.SourceTool) + } + if agents.Scope != ScopeUser { + t.Fatalf("global pi agents scope = %q, want user", agents.Scope) + } + + system := findRuleItem(t, items, filepath.Join(".pi", "agent", "SYSTEM.md")) + if system.SourceTool != "pi" { + t.Fatalf("global pi system sourceTool = %q, want pi", system.SourceTool) + } + if system.Scope != ScopeUser { + t.Fatalf("global pi system scope = %q, want user", system.Scope) + } + + appendSystem := findRuleItem(t, items, filepath.Join(".pi", "agent", "APPEND_SYSTEM.md")) + if appendSystem.SourceTool != "pi" { + t.Fatalf("global pi append sourceTool = %q, want pi", appendSystem.SourceTool) + } + if appendSystem.Scope != ScopeUser { + t.Fatalf("global pi append scope = %q, want user", appendSystem.Scope) + } +} + +func TestScanRules_ProjectRootAgentsStaysCodexWhenPiFilesExist(t *testing.T) { + project := t.TempDir() + agentsPath := filepath.Join(project, "AGENTS.md") + mustWriteFile(t, agentsPath, "# Project Codex") + mustWriteFile(t, filepath.Join(project, ".pi", "SYSTEM.md"), "# System") + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("ScanRules() warnings = %v", warnings) + } + + var codexRootCount, piRootCount int + for _, item := range items { + if item.Path != agentsPath { + continue + } + switch item.SourceTool { + case "codex": + codexRootCount++ + case "pi": + piRootCount++ + } + } + + if codexRootCount != 1 { + t.Fatalf("root AGENTS codex count = %d, want 1; items = %#v", codexRootCount, items) + } + if piRootCount != 0 { + t.Fatalf("root AGENTS pi count = %d, want 0; items = %#v", piRootCount, items) + } +} + +func TestScanRules_MalformedFrontmatterDegradesToUnscoped(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, ".claude", "rules", "broken.md"), "---\npaths: [src/**\n---\n# Broken") + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warnings for malformed frontmatter") + } + item := items[0] + if item.IsScoped { + t.Fatal("malformed frontmatter should degrade to unscoped") + } + if len(item.ScopedPaths) != 0 { + t.Fatalf("scoped paths = %v, want none", item.ScopedPaths) + } +} + +func TestScanRules_LaterBodyDelimiterDoesNotCreateScope(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + path := filepath.Join(project, ".claude", "rules", "later.md") + mustWriteFile(t, path, "# Body first\n---\npaths:\n - src/**\n---") + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + item := items[0] + if item.Path != path { + t.Fatalf("path = %q, want %q", item.Path, path) + } + if item.IsScoped { + t.Fatal("later body delimiter should not create scope") + } + if len(item.ScopedPaths) != 0 { + t.Fatalf("scoped paths = %v, want none", item.ScopedPaths) + } +} + +func TestScanRules_UnclosedLeadingFrontmatterDoesNotCreateScope(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + path := filepath.Join(project, ".claude", "rules", "unclosed.md") + mustWriteFile(t, path, "---\npaths:\n - src/**\n# missing closing delimiter") + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warnings for unclosed frontmatter") + } + item := items[0] + if item.Path != path { + t.Fatalf("path = %q, want %q", item.Path, path) + } + if item.IsScoped { + t.Fatal("unclosed frontmatter should not create scope") + } + if len(item.ScopedPaths) != 0 { + t.Fatalf("scoped paths = %v, want none", item.ScopedPaths) + } +} + +func TestScanRules_NonFrontmatterPrefixDoesNotCreateScope(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + path := filepath.Join(project, ".claude", "rules", "prefix.md") + mustWriteFile(t, path, strings.Join([]string{ + "---not-frontmatter", + "Body text before a real-looking block.", + "---", + "paths:", + " - src/**", + "---", + "# Trailing body", + }, "\n")) + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + item := items[0] + if item.Path != path { + t.Fatalf("path = %q, want %q", item.Path, path) + } + if item.IsScoped { + t.Fatal("prefix line should not be treated as frontmatter") + } + if len(item.ScopedPaths) != 0 { + t.Fatalf("scoped paths = %v, want none", item.ScopedPaths) + } +} + +func TestScanRules_TrailingSpaceDelimitersDoNotCount(t *testing.T) { + tests := []struct { + name string + content string + }{ + { + name: "opening delimiter with trailing space", + content: strings.Join([]string{ + "--- ", + "paths:", + " - src/**", + "---", + "# Body", + }, "\n"), + }, + { + name: "closing delimiter with trailing space", + content: strings.Join([]string{ + "---", + "paths:", + " - src/**", + "--- ", + "# Body", + }, "\n"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + path := filepath.Join(project, ".claude", "rules", "trailing-space.md") + mustWriteFile(t, path, tt.content) + + t.Setenv("HOME", home) + + items, _, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + item := items[0] + if item.IsScoped { + t.Fatal("trailing-space delimiter should not create scope") + } + if len(item.ScopedPaths) != 0 { + t.Fatalf("scoped paths = %v, want none", item.ScopedPaths) + } + }) + } +} + +func TestScanRules_IndentedOpenerDoesNotCreateScope(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + path := filepath.Join(project, ".claude", "rules", "indented.md") + mustWriteFile(t, path, strings.Join([]string{ + " ---", + "paths:", + " - src/**", + "---", + "# Body", + }, "\n")) + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + item := items[0] + if item.Path != path { + t.Fatalf("path = %q, want %q", item.Path, path) + } + if item.IsScoped { + t.Fatal("indented opener should not create scope") + } + if len(item.ScopedPaths) != 0 { + t.Fatalf("scoped paths = %v, want none", item.ScopedPaths) + } +} + +func TestScanRules_MixedTypePathsDegradeSafely(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + path := filepath.Join(project, ".claude", "rules", "mixed.md") + mustWriteFile(t, path, strings.Join([]string{ + "---", + "paths: [src/**, 123]", + "---", + "# Body", + }, "\n")) + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for mixed-type paths frontmatter") + } + item := items[0] + if item.IsScoped { + t.Fatal("mixed-type paths should not create scope") + } + if len(item.ScopedPaths) != 0 { + t.Fatalf("scoped paths = %v, want none", item.ScopedPaths) + } +} + +func TestScanRules_SkipsNonTextRulesDirectoryFile(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + path := filepath.Join(project, ".claude", "rules", "binary.bin") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + if err := os.WriteFile(path, []byte{0x00, 0x01, 0x02, 0x03}, 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("expected 0 items, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for non-text rules directory file") + } +} + +func TestScanRules_SkipsBinaryLikeRulesDirectoryFile(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + path := filepath.Join(project, ".claude", "rules", "artifact.pdf") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + content := []byte("%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF") + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("expected 0 items, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for binary-like rules directory file") + } +} + +func TestScanRules_SkipsOversizedRulesDirectoryFile(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + path := filepath.Join(project, ".claude", "rules", "oversized.md") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + if err := os.WriteFile(path, bytes.Repeat([]byte("a"), maxRuleFileSize+1), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("expected 0 items, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for oversized rules directory file") + } +} + +func TestScanRules_ReadsSymlinkedRulesDirectoryFile(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + target := filepath.Join(tmp, "outside.md") + mustWriteFile(t, target, "# Outside content") + + link := filepath.Join(project, ".claude", "rules", "linked.md") + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", link, err) + } + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks not supported: %v", err) + } + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + item := items[0] + if item.Path != link { + t.Fatalf("path = %q, want symlink path %q", item.Path, link) + } + if item.Content != "# Outside content" { + t.Fatalf("content = %q, want %q", item.Content, "# Outside content") + } + if item.Scope != ScopeProject { + t.Fatalf("scope = %q, want project", item.Scope) + } + if item.SourceTool != "claude" { + t.Fatalf("sourceTool = %q, want claude", item.SourceTool) + } +} + +func TestScanRules_ParsesCRLFScope(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + path := filepath.Join(project, ".claude", "rules", "crlf.md") + mustWriteFile(t, path, strings.Join([]string{ + "---", + "paths:", + " - src/**", + "---", + "# Body", + }, "\r\n")) + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + item := items[0] + if !item.IsScoped { + t.Fatal("expected CRLF rule to be scoped") + } + if len(item.ScopedPaths) != 1 || item.ScopedPaths[0] != "src/**" { + t.Fatalf("scoped paths = %v, want [src/**]", item.ScopedPaths) + } +} + +func TestScanRules_ParsesLongFrontmatterLine(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + longPath := strings.Repeat("a", 70*1024) + path := filepath.Join(project, ".claude", "rules", "long.md") + mustWriteFile(t, path, strings.Join([]string{ + "---", + "paths:", + " - " + longPath, + "---", + "# Body", + }, "\n")) + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + item := items[0] + if !item.IsScoped { + t.Fatal("expected long frontmatter rule to be scoped") + } + if len(item.ScopedPaths) != 1 || item.ScopedPaths[0] != longPath { + t.Fatalf("scoped paths = %v, want [%s]", item.ScopedPaths, longPath) + } +} + +func TestScanRules_SkipsNonRegularRulesDirectoryEntry(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix domain sockets are not supported on windows") + } + + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + path := filepath.Join(project, ".claude", "rules", "socket.sock") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + listener, err := net.Listen("unix", path) + if err != nil { + t.Skipf("unable to create unix socket: %v", err) + } + t.Cleanup(func() { + listener.Close() + _ = os.Remove(path) + }) + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("expected 0 items, got %d", len(items)) + } + if len(warnings) == 0 { + t.Fatal("expected warning for non-regular rules directory entry") + } +} + +func TestScanRules_ReadsSymlinkedRulesDirectoryRoot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink behavior is platform-dependent on windows") + } + + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + externalRules := filepath.Join(tmp, "external-rules") + + mustWriteFile(t, filepath.Join(externalRules, "leak.md"), "# Leaked content") + + rulesRoot := filepath.Join(project, ".claude", "rules") + if err := os.MkdirAll(filepath.Dir(rulesRoot), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", rulesRoot, err) + } + if err := os.Symlink(externalRules, rulesRoot); err != nil { + t.Skipf("symlinks not supported: %v", err) + } + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item from symlinked rules root, got %d", len(items)) + } + item := items[0] + wantPath := filepath.Join(project, ".claude", "rules", "leak.md") + if item.Path != wantPath { + t.Fatalf("path = %q, want logical path %q", item.Path, wantPath) + } + if item.Content != "# Leaked content" { + t.Fatalf("content = %q, want %q", item.Content, "# Leaked content") + } + if item.Scope != ScopeProject { + t.Fatalf("scope = %q, want project", item.Scope) + } +} + +func TestScanRules_DedupesOverlappingHomeAndProjectRoots(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "workspace") + + mustWriteFile(t, filepath.Join(home, ".claude", "CLAUDE.md"), "# Shared Claude") + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(home) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + + item := items[0] + if item.Path != filepath.Join(home, ".claude", "CLAUDE.md") { + t.Fatalf("path = %q, want shared path", item.Path) + } + if item.Scope != ScopeProject { + t.Fatalf("scope = %q, want project scope to win", item.Scope) + } +} + +func TestScanRules_HomeAliasRootStillSkipsUserDuplicates(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink behavior is platform-dependent on windows") + } + + tmp := t.TempDir() + home := filepath.Join(tmp, "workspace") + alias := filepath.Join(tmp, "workspace-alias") + + mustWriteFile(t, filepath.Join(home, ".claude", "CLAUDE.md"), "# Shared Claude") + mustWriteFile(t, filepath.Join(home, ".claude", "rules", "shared.md"), "# Shared Rule") + + if err := os.Symlink(home, alias); err != nil { + t.Skipf("symlinks not supported: %v", err) + } + + t.Setenv("HOME", home) + + items, warnings, err := ScanRules(alias) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(items) != 2 { + t.Fatalf("expected 2 project items from alias root, got %d", len(items)) + } + for _, item := range items { + if item.Scope != ScopeProject { + t.Fatalf("scope = %q, want project for %s", item.Scope, item.Path) + } + if !strings.HasPrefix(item.Path, alias) { + t.Fatalf("path = %q, want alias-root path", item.Path) + } + } +} + +func TestScanRules_DirectKnownPathFIFOReturnsPromptly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fifo behavior is platform-dependent on windows") + } + + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + fifoPath := filepath.Join(home, ".claude", "CLAUDE.md") + if err := os.MkdirAll(filepath.Dir(fifoPath), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", fifoPath, err) + } + if err := createTestFIFO(fifoPath, 0o644); err != nil { + t.Skipf("unable to create fifo: %v", err) + } + + t.Setenv("HOME", home) + + type result struct { + items []RuleItem + warnings []string + err error + } + done := make(chan result, 1) + go func() { + items, warnings, err := ScanRules("") + done <- result{items: items, warnings: warnings, err: err} + }() + + select { + case res := <-done: + if res.err != nil { + t.Fatalf("ScanRules() error = %v", res.err) + } + if len(res.items) != 0 { + t.Fatalf("expected 0 items, got %d", len(res.items)) + } + if len(res.warnings) == 0 { + t.Fatal("expected warning for fifo rule file") + } + case <-time.After(2 * time.Second): + t.Fatal("ScanRules() hung on fifo rule file") + } +} + +func TestScanRules_CollectibleMetadata(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + mustWriteFile(t, filepath.Join(project, "AGENTS.md"), "# Project Codex") + mustWriteFile(t, filepath.Join(project, ".claude", "rules", "backend.md"), "---\npaths:\n - src/**\n---\n# Backend") + + t.Setenv("HOME", home) + + first, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + + second, warnings, err := ScanRules(project) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + + firstByPath := make(map[string]RuleItem, len(first)) + for _, item := range first { + firstByPath[item.Path] = item + if strings.TrimSpace(item.ID) == "" { + t.Fatalf("id for %s should be non-empty", item.Path) + } + if !item.Collectible { + t.Fatalf("collectible for %s = false, want true", item.Path) + } + if item.CollectReason != "" { + t.Fatalf("collectReason for %s = %q, want empty", item.Path, item.CollectReason) + } + } + + for _, item := range second { + before, ok := firstByPath[item.Path] + if !ok { + t.Fatalf("path %s missing from first scan", item.Path) + } + if before.ID != item.ID { + t.Fatalf("id for %s changed across scans: %q != %q", item.Path, before.ID, item.ID) + } + } +} diff --git a/internal/inspect/types.go b/internal/inspect/types.go new file mode 100644 index 00000000..fdd57f8b --- /dev/null +++ b/internal/inspect/types.go @@ -0,0 +1,46 @@ +package inspect + +type Scope string + +const ( + ScopeUser Scope = "user" + ScopeProject Scope = "project" +) + +type RuleItem struct { + Name string `json:"name"` + ID string `json:"id"` + SourceTool string `json:"sourceTool"` + Scope Scope `json:"scope"` + Path string `json:"path"` + Exists bool `json:"exists"` + Collectible bool `json:"collectible"` + CollectReason string `json:"collectReason,omitempty"` + Content string `json:"content"` + Size int64 `json:"size"` + ScopedPaths []string `json:"scopedPaths,omitempty"` + IsScoped bool `json:"isScoped"` +} + +type HookItem struct { + SourceTool string `json:"sourceTool"` + Scope Scope `json:"scope"` + Event string `json:"event"` + Matcher string `json:"matcher,omitempty"` + GroupID string `json:"groupId"` + Collectible bool `json:"collectible"` + CollectReason string `json:"collectReason,omitempty"` + Sequential *bool `json:"sequential,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Command string `json:"command"` + URL string `json:"url,omitempty"` + Prompt string `json:"prompt,omitempty"` + Timeout string `json:"timeout,omitempty"` + TimeoutSeconds *int `json:"timeoutSec,omitempty"` + StatusMessage string `json:"statusMessage,omitempty"` + EntryIndex int `json:"-"` + ActionIndex int `json:"-"` + ActionType string `json:"actionType"` + Path string `json:"path"` +} diff --git a/internal/resources/adapters/claude_hooks.go b/internal/resources/adapters/claude_hooks.go new file mode 100644 index 00000000..a4bcab42 --- /dev/null +++ b/internal/resources/adapters/claude_hooks.go @@ -0,0 +1,63 @@ +package adapters + +import ( + "path/filepath" +) + +// CompileClaudeHooks compiles managed claude hooks into target-native files. +func CompileClaudeHooks(records []HookRecord, projectRoot, rawConfig string) ([]CompiledFile, []string, error) { + document, warnings, err := buildHookDocument(records, func(handler HookHandler) (hookJSONAction, string, bool) { + actionType := handler.Type + switch actionType { + case "command": + if handler.Command == "" { + return hookJSONAction{}, "", false + } + return hookJSONAction{ + Type: "command", + Command: handler.Command, + Timeout: handler.Timeout, + StatusMessage: handler.StatusMessage, + }, "", true + case "http": + if handler.URL == "" { + return hookJSONAction{}, "", false + } + return hookJSONAction{ + Type: "http", + URL: handler.URL, + Timeout: handler.Timeout, + StatusMessage: handler.StatusMessage, + }, "", true + case "prompt", "agent": + if handler.Prompt == "" { + return hookJSONAction{}, "", false + } + return hookJSONAction{ + Type: actionType, + Prompt: handler.Prompt, + Timeout: handler.Timeout, + StatusMessage: handler.StatusMessage, + }, "", true + default: + return hookJSONAction{}, "skipping unsupported claude hook type " + actionType, false + } + }) + if err != nil { + return nil, nil, err + } + if document == nil { + document = map[string][]hookJSONEntry{} + } + + mergedConfig, err := mergeJSONConfig(rawConfig, map[string]any{"hooks": document}) + if err != nil { + return nil, nil, err + } + + return []CompiledFile{{ + Path: filepath.Join(projectRoot, ".claude", "settings.json"), + Content: mergedConfig, + Format: "json", + }}, warnings, nil +} diff --git a/internal/resources/adapters/claude_rules.go b/internal/resources/adapters/claude_rules.go new file mode 100644 index 00000000..4bf109e2 --- /dev/null +++ b/internal/resources/adapters/claude_rules.go @@ -0,0 +1,114 @@ +package adapters + +import ( + "path" + "path/filepath" + "sort" + "strings" +) + +// CompileClaudeRules compiles managed claude rules into target-native files. +func CompileClaudeRules(records []RuleRecord, projectRoot string) ([]CompiledFile, []string, error) { + sorted := append([]RuleRecord(nil), records...) + sort.Slice(sorted, func(i, j int) bool { + return normalizeRulePath(sorted[i]) < normalizeRulePath(sorted[j]) + }) + + var ( + files []CompiledFile + warnings []string + instruction *RuleRecord + otherRules []RuleRecord + ) + + for _, record := range sorted { + if strings.TrimSpace(record.Tool) != "" && strings.TrimSpace(record.Tool) != "claude" { + continue + } + rel := normalizeRulePath(record) + toolRelative := trimToolPrefix(rel, "claude") + if isInstructionRule(toolRelative, record.Name, "CLAUDE.md") { + if instruction != nil { + warnings = append(warnings, "multiple claude instruction rules found; using the first one") + continue + } + copy := record + instruction = © + continue + } + otherRules = append(otherRules, record) + } + + if instruction != nil { + files = append(files, CompiledFile{ + Path: filepath.Join(projectRoot, "CLAUDE.md"), + Content: instruction.Content, + Format: "markdown", + }) + } + + for _, rule := range otherRules { + rel := trimToolPrefix(normalizeRulePath(rule), "claude") + files = append(files, CompiledFile{ + Path: filepath.Join(ruleOutputBaseDir(projectRoot, ".claude"), filepath.FromSlash(rel)), + Content: rule.Content, + Format: "markdown", + }) + } + + return files, warnings, nil +} + +func normalizeRulePath(record RuleRecord) string { + rel := filepath.ToSlash(strings.TrimSpace(record.RelativePath)) + if rel == "" { + rel = filepath.ToSlash(strings.TrimSpace(record.ID)) + } + if rel == "" { + rel = strings.TrimSpace(record.Name) + } + if rel == "" { + return "" + } + rel = path.Clean(rel) + if rel == "." { + return "" + } + return rel +} + +func trimToolPrefix(rel, tool string) string { + if rel == "" { + return "" + } + prefix := tool + "/" + if strings.HasPrefix(rel, prefix) { + rel = strings.TrimPrefix(rel, prefix) + } + return strings.TrimPrefix(rel, "/") +} + +func isInstructionRule(rel, name, instructionName string) bool { + trimmed := strings.Trim(strings.TrimSpace(rel), "/") + if trimmed != "" { + if strings.Contains(trimmed, "/") { + return false + } + return strings.EqualFold(trimmed, instructionName) + } + if strings.Contains(strings.TrimSpace(name), "/") { + return false + } + if strings.EqualFold(path.Base(strings.TrimSpace(name)), instructionName) { + return true + } + return strings.EqualFold(strings.TrimSpace(name), instructionName) +} + +func ruleOutputBaseDir(root, configDirName string) string { + cleaned := filepath.Clean(strings.TrimSpace(root)) + if strings.EqualFold(filepath.Base(cleaned), configDirName) { + return filepath.Join(cleaned, "rules") + } + return filepath.Join(cleaned, configDirName, "rules") +} diff --git a/internal/resources/adapters/codex_hooks.go b/internal/resources/adapters/codex_hooks.go new file mode 100644 index 00000000..9e02eaa6 --- /dev/null +++ b/internal/resources/adapters/codex_hooks.go @@ -0,0 +1,514 @@ +package adapters + +import ( + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +var ( + codexFeaturesHeaderRE = regexp.MustCompile(`^\s*\[features\]\s*(?:#.*)?$`) + codexAnyTableHeaderRE = regexp.MustCompile(`^\s*\[\[?[^\[\]]+\]\]?\s*(?:#.*)?$`) + codexHooksAssignmentRE = regexp.MustCompile(`^(\s*codex_hooks\s*=\s*)([^#\r\n]*?)(\s*(#.*)?)$`) +) + +// CompileCodexHooks compiles managed codex hooks into target-native files. +func CompileCodexHooks(records []HookRecord, projectRoot, rawConfig string) ([]CompiledFile, []string, error) { + filtered := make([]HookRecord, 0, len(records)) + warnings := make([]string, 0) + for _, record := range records { + if !isSupportedCodexEvent(record.Event) { + warnings = append(warnings, "skipping hook "+strings.TrimSpace(record.ID)+": unsupported codex event "+strings.TrimSpace(record.Event)) + continue + } + filtered = append(filtered, record) + } + + document, buildWarnings, err := buildHookDocument(filtered, func(handler HookHandler) (hookJSONAction, string, bool) { + if handler.Type != "command" { + return hookJSONAction{}, "skipping unsupported codex hook type " + handler.Type, false + } + if handler.Command == "" { + return hookJSONAction{}, "", false + } + timeout, ok := codexTimeoutJSONValue(handler) + if !ok { + return hookJSONAction{}, "skipping codex hook with non-numeric timeout", false + } + return hookJSONAction{ + Type: "command", + Command: handler.Command, + Timeout: timeout, + StatusMessage: handler.StatusMessage, + }, "", true + }) + if err != nil { + return nil, nil, err + } + warnings = append(warnings, buildWarnings...) + if document == nil { + document = map[string][]hookJSONEntry{} + } + + config, err := mergeCodexConfig(rawConfig, len(document) > 0) + if err != nil { + return nil, nil, err + } + + hooksJSON, err := json.Marshal(map[string]any{"hooks": document}) + if err != nil { + return nil, nil, err + } + + return []CompiledFile{ + { + Path: filepath.Join(projectRoot, ".codex", "config.toml"), + Content: config, + Format: "toml", + }, + { + Path: filepath.Join(projectRoot, ".codex", "hooks.json"), + Content: string(hooksJSON), + Format: "json", + }, + }, warnings, nil +} + +func isSupportedCodexEvent(event string) bool { + switch strings.TrimSpace(event) { + case "SessionStart", "PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop": + return true + default: + return false + } +} + +func mergeCodexConfig(raw string, enabled bool) (string, error) { + if strings.TrimSpace(raw) == "" { + return minimalCodexConfig(enabled), nil + } + if err := validateCodexConfigFeatures(raw); err != nil { + return "", err + } + if merged, ok := patchExplicitCodexFeaturesTable(raw, enabled); ok { + return merged, nil + } + if merged, ok := patchInlineCodexFeaturesTable(raw, enabled); ok { + return merged, nil + } + return raw + newlineFor(raw) + minimalCodexConfig(enabled), nil +} + +func codexTimeoutJSONValue(handler HookHandler) (any, bool) { + if handler.TimeoutSeconds != nil { + return *handler.TimeoutSeconds, true + } + + timeout := strings.TrimSpace(handler.Timeout) + if timeout == "" { + return nil, true + } + seconds, err := strconv.Atoi(timeout) + if err != nil { + return nil, false + } + return seconds, true +} + +func validateCodexConfigFeatures(raw string) error { + doc := map[string]any{} + if err := toml.Unmarshal([]byte(raw), &doc); err != nil { + return err + } + featuresValue, exists := doc["features"] + if !exists { + return nil + } + if _, ok := featuresValue.(map[string]any); !ok { + return fmt.Errorf("codex config features must be a table") + } + return nil +} + +func patchExplicitCodexFeaturesTable(raw string, enabled bool) (string, bool) { + spans := codexLineSpans(raw) + for i, span := range spans { + body := trimCodexLineEnding(raw[span.start:span.end]) + if !codexFeaturesHeaderRE.MatchString(body) { + continue + } + blockEnd := len(raw) + for j := i + 1; j < len(spans); j++ { + nextBody := trimCodexLineEnding(raw[spans[j].start:spans[j].end]) + if codexAnyTableHeaderRE.MatchString(nextBody) { + blockEnd = spans[j].start + break + } + } + block := raw[span.start:blockEnd] + if updated, ok := patchCodexHooksLineInBlock(block, enabled); ok { + return raw[:span.start] + updated + raw[blockEnd:], true + } + insertAt := span.start + lastCodexNonBlankLineEnd(block) + insert := minimalCodexAssignment(enabled) + newlineFor(raw) + return raw[:insertAt] + insert + raw[insertAt:], true + } + return "", false +} + +func patchInlineCodexFeaturesTable(raw string, enabled bool) (string, bool) { + spans := codexLineSpans(raw) + for _, span := range spans { + body := trimCodexLineEnding(raw[span.start:span.end]) + if codexAnyTableHeaderRE.MatchString(body) { + return "", false + } + updated, ok := patchInlineCodexFeaturesLine(body, enabled) + if !ok { + continue + } + return raw[:span.start] + updated + raw[span.end:], true + } + return "", false +} + +func patchInlineCodexFeaturesLine(line string, enabled bool) (string, bool) { + openBrace, closeBrace, ok := locateInlineCodexFeaturesTable(line) + if !ok { + return "", false + } + inside := line[openBrace+1 : closeBrace] + patchedInside, ok := patchInlineCodexFeaturesBody(inside, enabled) + if !ok { + return "", false + } + return line[:openBrace+1] + patchedInside + line[closeBrace:], true +} + +func locateInlineCodexFeaturesTable(line string) (int, int, bool) { + i := 0 + for i < len(line) && (line[i] == ' ' || line[i] == '\t') { + i++ + } + if !strings.HasPrefix(line[i:], "features") { + return 0, 0, false + } + i += len("features") + if i < len(line) && isInlineCodexIdentifierChar(line[i]) { + return 0, 0, false + } + for i < len(line) && (line[i] == ' ' || line[i] == '\t') { + i++ + } + if i >= len(line) || line[i] != '=' { + return 0, 0, false + } + i++ + for i < len(line) && (line[i] == ' ' || line[i] == '\t') { + i++ + } + if i >= len(line) || line[i] != '{' { + return 0, 0, false + } + + openBrace := i + closeBrace, ok := findInlineCodexTableClose(line, openBrace) + if !ok { + return 0, 0, false + } + if !inlineCodexFeaturesSuffixAllowed(line[closeBrace+1:]) { + return 0, 0, false + } + return openBrace, closeBrace, true +} + +func findInlineCodexTableClose(line string, openBrace int) (int, bool) { + quote := byte(0) + escape := false + braceDepth := 0 + bracketDepth := 0 + + for i := openBrace; i < len(line); i++ { + c := line[i] + if quote != 0 { + if quote == '"' { + if escape { + escape = false + continue + } + if c == '\\' { + escape = true + continue + } + } + if c == quote { + quote = 0 + } + continue + } + + switch c { + case '"', '\'': + quote = c + case '{': + braceDepth++ + case '}': + braceDepth-- + if braceDepth == 0 { + return i, true + } + case '[': + bracketDepth++ + case ']': + if bracketDepth > 0 { + bracketDepth-- + } + } + } + + return 0, false +} + +func inlineCodexFeaturesSuffixAllowed(suffix string) bool { + trimmed := strings.TrimSpace(suffix) + return trimmed == "" || strings.HasPrefix(trimmed, "#") +} + +func isInlineCodexIdentifierChar(ch byte) bool { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' +} + +func patchInlineCodexFeaturesBody(body string, enabled bool) (string, bool) { + trimmed := strings.TrimSpace(body) + if trimmed == "" { + return " codex_hooks = " + strconv.FormatBool(enabled) + " ", true + } + if valueStart, valueEnd, ok := findInlineCodexHooksValueSpan(body); ok { + return body[:valueStart] + strconv.FormatBool(enabled) + body[valueEnd:], true + } + if strings.HasSuffix(trimmed, ",") { + return body + " codex_hooks = " + strconv.FormatBool(enabled) + " ", true + } + return body + ", codex_hooks = " + strconv.FormatBool(enabled), true +} + +func findInlineCodexHooksValueSpan(body string) (int, int, bool) { + const key = "codex_hooks" + quote := byte(0) + escape := false + braceDepth := 0 + bracketDepth := 0 + for i := 0; i < len(body); i++ { + c := body[i] + if quote != 0 { + if quote == '"' { + if escape { + escape = false + continue + } + if c == '\\' { + escape = true + continue + } + } + if c == quote { + quote = 0 + } + continue + } + + switch c { + case '"', '\'': + quote = c + continue + case '{': + braceDepth++ + continue + case '}': + if braceDepth > 0 { + braceDepth-- + } + continue + case '[': + bracketDepth++ + continue + case ']': + if bracketDepth > 0 { + bracketDepth-- + } + continue + } + + if braceDepth != 0 || bracketDepth != 0 || c != key[0] { + continue + } + if !hasInlineCodexHooksKeyPrefix(body, i) { + continue + } + + j := i + len(key) + for j < len(body) && isInlineSpace(body[j]) { + j++ + } + if j >= len(body) || body[j] != '=' { + continue + } + j++ + for j < len(body) && isInlineSpace(body[j]) { + j++ + } + return j, findInlineCodexHooksValueEnd(body, j), true + } + return 0, 0, false +} + +func hasInlineCodexHooksKeyPrefix(body string, idx int) bool { + const key = "codex_hooks" + if !strings.HasPrefix(body[idx:], key) { + return false + } + if idx > 0 { + prev := body[idx-1] + if isInlineIdentChar(prev) || prev == '.' { + return false + } + } + if next := idx + len(key); next < len(body) { + if isInlineIdentChar(body[next]) { + return false + } + } + return true +} + +func findInlineCodexHooksValueEnd(body string, start int) int { + quote := byte(0) + escape := false + braceDepth := 0 + bracketDepth := 0 + for i := start; i < len(body); i++ { + c := body[i] + if quote != 0 { + if quote == '"' { + if escape { + escape = false + continue + } + if c == '\\' { + escape = true + continue + } + } + if c == quote { + quote = 0 + } + continue + } + + switch c { + case '"', '\'': + quote = c + case '{': + braceDepth++ + case '}': + if braceDepth > 0 { + braceDepth-- + } + case '[': + bracketDepth++ + case ']': + if bracketDepth > 0 { + bracketDepth-- + } + case ',': + if braceDepth == 0 && bracketDepth == 0 { + return i + } + } + } + return len(body) +} + +func isInlineSpace(b byte) bool { + return b == ' ' || b == '\t' +} + +func isInlineIdentChar(b byte) bool { + return b == '_' || b == '-' || (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') +} + +func patchCodexHooksLineInBlock(block string, enabled bool) (string, bool) { + spans := codexLineSpans(block) + for _, span := range spans { + line := block[span.start:span.end] + body := trimCodexLineEnding(line) + updatedBody, ok := patchCodexHooksLine(body, enabled) + if !ok { + continue + } + return block[:span.start] + updatedBody + line[len(body):] + block[span.end:], true + } + return "", false +} + +func patchCodexHooksLine(body string, enabled bool) (string, bool) { + match := codexHooksAssignmentRE.FindStringSubmatchIndex(body) + if match == nil { + return "", false + } + return body[:match[2]] + strconv.FormatBool(enabled) + body[match[3]:], true +} + +func lastCodexNonBlankLineEnd(block string) int { + spans := codexLineSpans(block) + for i := len(spans) - 1; i >= 0; i-- { + line := trimCodexLineEnding(block[spans[i].start:spans[i].end]) + if strings.TrimSpace(line) != "" { + return spans[i].end + } + } + return len(block) +} + +func codexLineSpans(raw string) []struct{ start, end int } { + if raw == "" { + return nil + } + spans := make([]struct{ start, end int }, 0, strings.Count(raw, "\n")+1) + start := 0 + for start < len(raw) { + next := strings.IndexByte(raw[start:], '\n') + if next < 0 { + spans = append(spans, struct{ start, end int }{start: start, end: len(raw)}) + break + } + end := start + next + 1 + spans = append(spans, struct{ start, end int }{start: start, end: end}) + start = end + } + return spans +} + +func trimCodexLineEnding(line string) string { + return strings.TrimRight(line, "\r\n") +} + +func minimalCodexConfig(enabled bool) string { + return "[features]\n" + minimalCodexAssignment(enabled) + "\n" +} + +func minimalCodexAssignment(enabled bool) string { + return "codex_hooks = " + strconv.FormatBool(enabled) +} + +func newlineFor(raw string) string { + if strings.Contains(raw, "\r\n") { + return "\r\n" + } + return "\n" +} diff --git a/internal/resources/adapters/codex_rules.go b/internal/resources/adapters/codex_rules.go new file mode 100644 index 00000000..130c5abe --- /dev/null +++ b/internal/resources/adapters/codex_rules.go @@ -0,0 +1,45 @@ +package adapters + +import ( + "fmt" + "path/filepath" + "sort" + "strings" +) + +// CompileCodexRules compiles managed codex rules into one AGENTS.md file. +func CompileCodexRules(records []RuleRecord, projectRoot string) ([]CompiledFile, []string, error) { + sorted := append([]RuleRecord(nil), records...) + sort.Slice(sorted, func(i, j int) bool { + return normalizeRulePath(sorted[i]) < normalizeRulePath(sorted[j]) + }) + + segments := make([]string, 0, len(sorted)) + for _, record := range sorted { + if strings.TrimSpace(record.Tool) != "" && strings.TrimSpace(record.Tool) != "codex" { + continue + } + + rel := normalizeRulePath(record) + if rel == "" { + continue + } + if !strings.HasPrefix(rel, "codex/") { + rel = "codex/" + strings.TrimPrefix(rel, "/") + } + + segments = append(segments, + fmt.Sprintf("\n%s", rel, strings.TrimSpace(record.Content)), + ) + } + + if len(segments) == 0 { + return nil, nil, nil + } + + return []CompiledFile{{ + Path: filepath.Join(projectRoot, "AGENTS.md"), + Content: strings.Join(segments, "\n\n"), + Format: "markdown", + }}, nil, nil +} diff --git a/internal/resources/adapters/gemini_hooks.go b/internal/resources/adapters/gemini_hooks.go new file mode 100644 index 00000000..93854841 --- /dev/null +++ b/internal/resources/adapters/gemini_hooks.go @@ -0,0 +1,85 @@ +package adapters + +import ( + "path/filepath" + "strconv" + "strings" +) + +// CompileGeminiHooks compiles managed gemini hooks into target-native files. +func CompileGeminiHooks(records []HookRecord, projectRoot, rawConfig string) ([]CompiledFile, []string, error) { + filtered := make([]HookRecord, 0, len(records)) + warnings := make([]string, 0) + for _, record := range records { + if !isSupportedGeminiEvent(record.Event) { + warnings = append(warnings, "skipping hook "+strings.TrimSpace(record.ID)+": unsupported gemini event "+strings.TrimSpace(record.Event)) + continue + } + filtered = append(filtered, record) + } + + document, buildWarnings, err := buildHookDocument(filtered, func(handler HookHandler) (hookJSONAction, string, bool) { + if handler.Type != "command" { + return hookJSONAction{}, "skipping unsupported gemini hook type " + handler.Type, false + } + if strings.TrimSpace(handler.Command) == "" { + return hookJSONAction{}, "", false + } + timeout, ok := geminiTimeoutJSONValue(handler) + if !ok { + return hookJSONAction{}, "skipping gemini hook with non-numeric timeout", false + } + return hookJSONAction{ + Type: "command", + Name: strings.TrimSpace(handler.Name), + Description: strings.TrimSpace(handler.Description), + Command: strings.TrimSpace(handler.Command), + Timeout: timeout, + StatusMessage: strings.TrimSpace(handler.StatusMessage), + }, "", true + }) + if err != nil { + return nil, nil, err + } + warnings = append(warnings, buildWarnings...) + if document == nil { + document = map[string][]hookJSONEntry{} + } + + mergedConfig, err := mergeJSONConfig(rawConfig, map[string]any{"hooks": document}) + if err != nil { + return nil, nil, err + } + + return []CompiledFile{{ + Path: filepath.Join(projectRoot, ".gemini", "settings.json"), + Content: mergedConfig, + Format: "json", + }}, warnings, nil +} + +func isSupportedGeminiEvent(event string) bool { + switch strings.TrimSpace(event) { + case "SessionStart", "SessionEnd", "BeforeAgent", "AfterAgent", "BeforeModel", "AfterModel", "BeforeToolSelection", "BeforeTool", "AfterTool", "PreCompress", "Notification": + return true + default: + return false + } +} + +func geminiTimeoutJSONValue(handler HookHandler) (any, bool) { + timeout := strings.TrimSpace(handler.Timeout) + if timeout != "" { + milliseconds, err := strconv.Atoi(timeout) + if err == nil { + return milliseconds, true + } + if handler.TimeoutSeconds == nil { + return nil, false + } + } + if handler.TimeoutSeconds == nil { + return nil, true + } + return *handler.TimeoutSeconds, true +} diff --git a/internal/resources/adapters/gemini_rules.go b/internal/resources/adapters/gemini_rules.go new file mode 100644 index 00000000..2e7aa614 --- /dev/null +++ b/internal/resources/adapters/gemini_rules.go @@ -0,0 +1,59 @@ +package adapters + +import ( + "path/filepath" + "sort" + "strings" +) + +// CompileGeminiRules compiles managed gemini rules into target-native files. +func CompileGeminiRules(records []RuleRecord, projectRoot string) ([]CompiledFile, []string, error) { + sorted := append([]RuleRecord(nil), records...) + sort.Slice(sorted, func(i, j int) bool { + return normalizeRulePath(sorted[i]) < normalizeRulePath(sorted[j]) + }) + + var ( + files []CompiledFile + warnings []string + instruction *RuleRecord + otherRules []RuleRecord + ) + + for _, record := range sorted { + if strings.TrimSpace(record.Tool) != "" && strings.TrimSpace(record.Tool) != "gemini" { + continue + } + rel := normalizeRulePath(record) + toolRelative := trimToolPrefix(rel, "gemini") + if isInstructionRule(toolRelative, record.Name, "GEMINI.md") { + if instruction != nil { + warnings = append(warnings, "multiple gemini instruction rules found; using the first one") + continue + } + copy := record + instruction = © + continue + } + otherRules = append(otherRules, record) + } + + if instruction != nil { + files = append(files, CompiledFile{ + Path: filepath.Join(projectRoot, "GEMINI.md"), + Content: instruction.Content, + Format: "markdown", + }) + } + + for _, rule := range otherRules { + rel := trimToolPrefix(normalizeRulePath(rule), "gemini") + files = append(files, CompiledFile{ + Path: filepath.Join(ruleOutputBaseDir(projectRoot, ".gemini"), filepath.FromSlash(rel)), + Content: rule.Content, + Format: "markdown", + }) + } + + return files, warnings, nil +} diff --git a/internal/resources/adapters/hooks_common.go b/internal/resources/adapters/hooks_common.go new file mode 100644 index 00000000..bbbcb1c9 --- /dev/null +++ b/internal/resources/adapters/hooks_common.go @@ -0,0 +1,261 @@ +package adapters + +import ( + "bytes" + "encoding/json" + "fmt" + "path" + "path/filepath" + "sort" + "strings" +) + +type hookJSONAction struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Command string `json:"command,omitempty"` + URL string `json:"url,omitempty"` + Prompt string `json:"prompt,omitempty"` + Timeout any `json:"timeout,omitempty"` + StatusMessage string `json:"statusMessage,omitempty"` +} + +type hookJSONEntry struct { + Matcher string `json:"matcher,omitempty"` + Sequential *bool `json:"sequential,omitempty"` + Hooks []hookJSONAction `json:"hooks"` +} + +func normalizeHookRecord(record HookRecord) (HookRecord, string, error) { + rel := strings.TrimSpace(record.RelativePath) + if rel == "" { + rel = strings.TrimSpace(record.ID) + } + rel = filepath.ToSlash(rel) + if rel != "" { + rel = path.Clean(rel) + } + if rel == "." { + rel = "" + } + if rel == "" { + return HookRecord{}, fmt.Sprintf("skipping hook %q: missing relative path", record.ID), nil + } + if strings.HasPrefix(rel, "../") || strings.HasPrefix(rel, "/") { + return HookRecord{}, "", fmt.Errorf("invalid managed hook path %q", rel) + } + + tool := strings.ToLower(strings.TrimSpace(record.Tool)) + if tool == "" { + if parts := strings.SplitN(rel, "/", 2); len(parts) > 1 && strings.TrimSpace(parts[0]) != "" { + tool = strings.ToLower(strings.TrimSpace(parts[0])) + } + } + if tool == "" { + return HookRecord{}, fmt.Sprintf("skipping hook %q: missing tool", record.ID), nil + } + + if !strings.HasPrefix(rel, tool+"/") { + rel = path.Join(tool, strings.TrimPrefix(rel, "/")) + } + + event := strings.TrimSpace(record.Event) + if event == "" { + return HookRecord{}, fmt.Sprintf("skipping hook %q: missing event", record.ID), nil + } + matcher := strings.TrimSpace(record.Matcher) + if tool == "codex" && (event == "UserPromptSubmit" || event == "Stop") { + matcher = "" + } + if matcher == "" && !hookAllowsEmptyMatcher(tool, event) { + return HookRecord{}, fmt.Sprintf("skipping hook %q: missing matcher", record.ID), nil + } + + record.Tool = tool + record.RelativePath = rel + record.Event = event + record.Matcher = matcher + return record, "", nil +} + +func buildHookDocument(records []HookRecord, allowHandler func(HookHandler) (hookJSONAction, string, bool)) (map[string][]hookJSONEntry, []string, error) { + if len(records) == 0 { + return nil, nil, nil + } + + sorted := append([]HookRecord(nil), records...) + sortHookRecords(sorted) + + document := make(map[string][]hookJSONEntry) + var warnings []string + + for _, record := range sorted { + normalized, warn, err := normalizeHookRecord(record) + if err != nil { + return nil, nil, err + } + if warn != "" { + warnings = append(warnings, warn) + continue + } + + actions := make([]hookJSONAction, 0, len(normalized.Handlers)) + for _, handler := range normalized.Handlers { + action, actionWarn, ok := allowHandler(handler) + if actionWarn != "" { + warnings = append(warnings, actionWarn) + } + if !ok { + continue + } + actions = append(actions, action) + } + if len(actions) == 0 { + warnings = append(warnings, fmt.Sprintf("skipping hook %q: no supported handlers", normalized.ID)) + continue + } + + document[normalized.Event] = append(document[normalized.Event], hookJSONEntry{ + Matcher: normalized.Matcher, + Sequential: normalized.Sequential, + Hooks: actions, + }) + } + + return document, warnings, nil +} + +func sortHookRecords(records []HookRecord) { + if len(records) < 2 { + return + } + sort.Slice(records, func(i, j int) bool { + return normalizedHookSortKey(records[i]) < normalizedHookSortKey(records[j]) + }) +} + +func normalizedHookSortKey(record HookRecord) string { + rel := strings.TrimSpace(record.RelativePath) + if rel == "" { + rel = strings.TrimSpace(record.ID) + } + rel = filepath.ToSlash(rel) + if rel == "" { + rel = strings.TrimSpace(record.Tool) + "/" + strings.TrimSpace(record.Event) + "/" + strings.TrimSpace(record.Matcher) + } + return path.Clean(rel) +} + +func encodeHookDocument(document map[string][]hookJSONEntry) ([]byte, error) { + return json.Marshal(map[string]any{"hooks": document}) +} + +func hookAllowsEmptyMatcher(tool, event string) bool { + normalizedTool := strings.ToLower(strings.TrimSpace(tool)) + normalizedEvent := strings.TrimSpace(event) + if normalizedTool == "codex" && (normalizedEvent == "UserPromptSubmit" || normalizedEvent == "Stop") { + return true + } + return normalizedTool == "gemini" +} + +func mergeJSONConfig(raw string, updates map[string]any) (string, error) { + doc := map[string]any{} + if strings.TrimSpace(raw) != "" { + decoded, err := stripJSONComments(raw) + if err != nil { + return "", err + } + if err := json.Unmarshal(decoded, &doc); err != nil { + return "", err + } + } + for key, value := range updates { + doc[key] = value + } + buf, err := json.Marshal(doc) + if err != nil { + return "", err + } + return string(buf), nil +} + +func stripJSONComments(raw string) ([]byte, error) { + raw = strings.TrimPrefix(raw, "\uFEFF") + if strings.TrimSpace(raw) == "" { + return []byte(raw), nil + } + + var out bytes.Buffer + out.Grow(len(raw)) + + inString := false + escaped := false + inLineComment := false + inBlockComment := false + + for i := 0; i < len(raw); i++ { + ch := raw[i] + + if inLineComment { + if ch == '\n' { + inLineComment = false + out.WriteByte(ch) + } + continue + } + if inBlockComment { + if ch == '*' && i+1 < len(raw) && raw[i+1] == '/' { + inBlockComment = false + i++ + continue + } + if ch == '\n' { + out.WriteByte(ch) + } + continue + } + if inString { + out.WriteByte(ch) + if escaped { + escaped = false + continue + } + if ch == '\\' { + escaped = true + continue + } + if ch == '"' { + inString = false + } + continue + } + + if ch == '"' { + inString = true + out.WriteByte(ch) + continue + } + if ch == '/' && i+1 < len(raw) { + switch raw[i+1] { + case '/': + inLineComment = true + i++ + continue + case '*': + inBlockComment = true + i++ + continue + } + } + + out.WriteByte(ch) + } + + if inBlockComment { + return nil, fmt.Errorf("unterminated JSON block comment") + } + + return out.Bytes(), nil +} diff --git a/internal/resources/adapters/hooks_test.go b/internal/resources/adapters/hooks_test.go new file mode 100644 index 00000000..e565d3b9 --- /dev/null +++ b/internal/resources/adapters/hooks_test.go @@ -0,0 +1,518 @@ +package adapters + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/pelletier/go-toml/v2" +) + +func TestMergeCodexConfig_PreservesExistingContent(t *testing.T) { + raw := "[profiles.default]\nmodel = \"gpt-5\"\n\n[ui]\ncompact = true\n" + + merged, err := mergeCodexConfig(raw, true) + if err != nil { + t.Fatalf("mergeCodexConfig() error = %v", err) + } + if !strings.Contains(merged, "codex_hooks = true") { + t.Fatalf("merged config missing feature flag: %q", merged) + } + if !strings.Contains(merged, "model =") || !strings.Contains(merged, "gpt-5") { + t.Fatalf("merged config missing original profile content: %q", merged) + } + if !strings.Contains(merged, "compact = true") { + t.Fatalf("merged config missing unrelated content: %q", merged) + } +} + +func TestMergeCodexConfig_PreservesCommentsAndFormatting(t *testing.T) { + raw := "# top comment\n[profiles.default]\n# keep profile comment\nmodel = \"gpt-5\" # inline comment\n\n[ui]\ncompact = true\n\n# features comment\n[features]\n# keep features comment\nalpha = true\n" + + merged, err := mergeCodexConfig(raw, true) + if err != nil { + t.Fatalf("mergeCodexConfig() error = %v", err) + } + for _, want := range []string{ + "# top comment", + "# keep profile comment", + "# inline comment", + "# features comment", + "# keep features comment", + "[profiles.default]", + "[ui]", + "[features]", + "codex_hooks = true", + "alpha = true", + } { + if !strings.Contains(merged, want) { + t.Fatalf("merged config missing %q: %q", want, merged) + } + } + + profilesIdx := strings.Index(merged, "[profiles.default]") + uiIdx := strings.Index(merged, "[ui]") + featuresIdx := strings.Index(merged, "[features]") + if profilesIdx < 0 || uiIdx < 0 || featuresIdx < 0 { + t.Fatalf("merged config missing expected table order: %q", merged) + } + if !(profilesIdx < uiIdx && uiIdx < featuresIdx) { + t.Fatalf("expected unrelated table order to be preserved: %q", merged) + } +} + +func TestMergeCodexConfig_UpdatesOnlyActualInlineCodexHooksKey(t *testing.T) { + raw := "features = { codex_hooks_enabled = true, note = \"codex_hooks\", codex_hooks = false }\n[profiles.default]\nmodel = \"gpt-5\"\n" + + merged, err := mergeCodexConfig(raw, true) + if err != nil { + t.Fatalf("mergeCodexConfig() error = %v", err) + } + if !strings.Contains(merged, "codex_hooks_enabled = true") { + t.Fatalf("merged config corrupted near-match key: %q", merged) + } + if !strings.Contains(merged, `note = "codex_hooks"`) { + t.Fatalf("merged config corrupted quoted string value: %q", merged) + } + if !strings.Contains(merged, "codex_hooks = true") { + t.Fatalf("merged config missing actual codex_hooks update: %q", merged) + } + if strings.Contains(merged, "codex_hooks = false") { + t.Fatalf("merged config did not update codex_hooks value: %q", merged) + } +} + +func TestMergeCodexConfig_UpdatesInlineCodexHooksOutsideQuotedString(t *testing.T) { + raw := "features = { note = \"x, codex_hooks = false\", codex_hooks = false }\n[profiles.default]\nmodel = \"gpt-5\"\n" + + merged, err := mergeCodexConfig(raw, true) + if err != nil { + t.Fatalf("mergeCodexConfig() error = %v", err) + } + if !strings.Contains(merged, `note = "x, codex_hooks = false"`) { + t.Fatalf("merged config corrupted quoted string value: %q", merged) + } + if !strings.Contains(merged, "codex_hooks = true") { + t.Fatalf("merged config missing actual codex_hooks update: %q", merged) + } + if strings.Contains(merged, "note = \"x, codex_hooks = true\"") { + t.Fatalf("merged config updated quoted string instead of codex_hooks key: %q", merged) + } +} + +func TestMergeCodexConfig_UpdatesNestedInlineTablesWithoutCorruption(t *testing.T) { + raw := "features = { nested = { enabled = true }, codex_hooks = false }\n[profiles.default]\nmodel = \"gpt-5\"\n" + + merged, err := mergeCodexConfig(raw, true) + if err != nil { + t.Fatalf("mergeCodexConfig() error = %v", err) + } + if !strings.Contains(merged, "nested = { enabled = true }") { + t.Fatalf("merged config corrupted nested inline table: %q", merged) + } + if !strings.Contains(merged, "codex_hooks = true") { + t.Fatalf("merged config missing codex_hooks update: %q", merged) + } +} + +func TestPatchInlineCodexFeaturesLine_RejectsTrailingInlineTableTokens(t *testing.T) { + line := "features = { codex_hooks = false }, other = { enabled = true }" + if _, ok := patchInlineCodexFeaturesLine(line, true); ok { + t.Fatalf("patchInlineCodexFeaturesLine() = ok for trailing tokens, want reject") + } +} + +func TestMergeCodexConfig_FormatsCodexHooksBeforeFollowingTable(t *testing.T) { + raw := "[features]\nalpha = true\n\n[ui]\ncompact = true\n" + + merged, err := mergeCodexConfig(raw, true) + if err != nil { + t.Fatalf("mergeCodexConfig() error = %v", err) + } + if !strings.Contains(merged, "alpha = true\ncodex_hooks = true") { + t.Fatalf("merged config missing codex_hooks line in features block: %q", merged) + } + if idx := strings.Index(merged, "[ui]"); idx < 0 || !strings.Contains(merged[:idx], "codex_hooks = true") { + t.Fatalf("merged config did not preserve table boundary formatting: %q", merged) + } +} + +func TestMergeCodexConfig_FormatsCodexHooksBeforeArrayOfTables(t *testing.T) { + raw := "[features]\nalpha = true\n\n[[rules]]\nname = \"one\"\n" + + merged, err := mergeCodexConfig(raw, false) + if err != nil { + t.Fatalf("mergeCodexConfig() error = %v", err) + } + featuresIdx := strings.Index(merged, "[features]") + rulesIdx := strings.Index(merged, "[[rules]]") + flagIdx := strings.Index(merged, "codex_hooks = false") + if featuresIdx < 0 || rulesIdx < 0 || flagIdx < 0 { + t.Fatalf("merged config missing expected blocks: %q", merged) + } + if !(featuresIdx < flagIdx && flagIdx < rulesIdx) { + t.Fatalf("expected codex_hooks to stay inside features block before array table: %q", merged) + } +} + +func TestMergeCodexConfig_PatchesIndentedFeaturesTable(t *testing.T) { + raw := " [features]\n alpha = true\n\n [ui]\n compact = true\n" + + merged, err := mergeCodexConfig(raw, true) + if err != nil { + t.Fatalf("mergeCodexConfig() error = %v", err) + } + if strings.Count(merged, "[features]") != 1 { + t.Fatalf("expected one features table, got %q", merged) + } + if !strings.Contains(merged, "codex_hooks = true") { + t.Fatalf("merged config missing codex_hooks line: %q", merged) + } + var parsed map[string]any + if err := toml.Unmarshal([]byte(merged), &parsed); err != nil { + t.Fatalf("merged config did not parse as TOML: %v; output=%q", err, merged) + } + if _, ok := parsed["features"]; !ok { + t.Fatalf("parsed TOML missing features table: %#v", parsed) + } +} + +func TestMergeCodexConfig_DisablesFeatureFlagWhenEmpty(t *testing.T) { + merged, err := mergeCodexConfig("", false) + if err != nil { + t.Fatalf("mergeCodexConfig() error = %v", err) + } + if !strings.Contains(merged, "codex_hooks = false") { + t.Fatalf("merged config missing disabled feature flag: %q", merged) + } +} + +func TestMergeCodexConfig_RejectsFeatureTypeConflict(t *testing.T) { + _, err := mergeCodexConfig("features = true\n", true) + if err == nil { + t.Fatal("mergeCodexConfig() error = nil, want conflict error") + } +} + +func TestCompileClaudeHooks_EmitsEmptyHooksSurface(t *testing.T) { + projectRoot := "/tmp/project" + + files, warnings, err := CompileClaudeHooks(nil, projectRoot, `{"profiles":{"default":{"model":"gpt-5"}}}`) + if err != nil { + t.Fatalf("CompileClaudeHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + + compiled := findHookCompiledFile(t, files, filepath.Join(projectRoot, ".claude", "settings.json")) + if !strings.Contains(compiled.Content, `"hooks":{}`) { + t.Fatalf("expected empty hooks object in claude output: %q", compiled.Content) + } + if !strings.Contains(compiled.Content, `"model":"gpt-5"`) { + t.Fatalf("expected raw config to be preserved: %q", compiled.Content) + } +} + +func TestCompileClaudeHooks_SupportsJSONWithComments(t *testing.T) { + projectRoot := "/tmp/project" + raw := "{\n // keep default profile\n \"profiles\": {\n \"default\": {\n \"model\": \"gpt-5\",\n \"endpoint\": \"https://example.com/api\"\n }\n },\n /* keep UI */\n \"ui\": { \"compact\": true }\n}\n" + + files, warnings, err := CompileClaudeHooks(nil, projectRoot, raw) + if err != nil { + t.Fatalf("CompileClaudeHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + + compiled := findHookCompiledFile(t, files, filepath.Join(projectRoot, ".claude", "settings.json")) + if !strings.Contains(compiled.Content, `"endpoint":"https://example.com/api"`) { + t.Fatalf("expected URL string to survive JSONC parsing: %q", compiled.Content) + } + if !strings.Contains(compiled.Content, `"hooks":{}`) { + t.Fatalf("expected merged hooks object in JSONC output: %q", compiled.Content) + } +} + +func TestCompileCodexHooks_EmitsEmptyHooksSurface(t *testing.T) { + projectRoot := "/tmp/project" + raw := "[profiles.default]\nmodel = \"gpt-5\"\n" + + files, warnings, err := CompileCodexHooks(nil, projectRoot, raw) + if err != nil { + t.Fatalf("CompileCodexHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + + config := findHookCompiledFile(t, files, filepath.Join(projectRoot, ".codex", "config.toml")) + if !strings.Contains(config.Content, "codex_hooks = false") { + t.Fatalf("expected disabled codex feature flag: %q", config.Content) + } + if !strings.Contains(config.Content, "gpt-5") { + t.Fatalf("expected raw config to be preserved: %q", config.Content) + } + + hooksJSON := findHookCompiledFile(t, files, filepath.Join(projectRoot, ".codex", "hooks.json")) + if !strings.Contains(hooksJSON.Content, `"hooks":{}`) { + t.Fatalf("expected empty hooks object in codex output: %q", hooksJSON.Content) + } +} + +func TestCompileCodexHooks_EmitsEmptyMatcherAndNumericTimeout(t *testing.T) { + projectRoot := "/tmp/project" + records := []HookRecord{ + { + ID: "codex/user-prompt-submit/matcher.yaml", + RelativePath: "codex/user-prompt-submit/matcher.yaml", + Tool: "codex", + Event: "UserPromptSubmit", + Matcher: "", + Handlers: []HookHandler{ + {Type: "command", Command: "./submit.sh", TimeoutSeconds: intPtr(30)}, + }, + }, + { + ID: "codex/stop/matcher.yaml", + RelativePath: "codex/stop/matcher.yaml", + Tool: "codex", + Event: "Stop", + Matcher: "", + Handlers: []HookHandler{ + {Type: "command", Command: "./stop.sh", TimeoutSeconds: intPtr(45)}, + }, + }, + } + + files, warnings, err := CompileCodexHooks(records, projectRoot, "") + if err != nil { + t.Fatalf("CompileCodexHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + + compiled := findHookCompiledFile(t, files, filepath.Join(projectRoot, ".codex", "hooks.json")) + if strings.Contains(compiled.Content, `"matcher"`) { + t.Fatalf("expected empty codex matcher to be omitted: %q", compiled.Content) + } + if !strings.Contains(compiled.Content, `"timeout":30`) || !strings.Contains(compiled.Content, `"timeout":45`) { + t.Fatalf("expected numeric timeout values in codex output: %q", compiled.Content) + } + if !strings.Contains(compiled.Content, "UserPromptSubmit") || !strings.Contains(compiled.Content, "Stop") { + t.Fatalf("expected codex events in output: %q", compiled.Content) + } +} + +func TestCompileCodexHooks_SkipsUnsupportedHandlers(t *testing.T) { + projectRoot := "/tmp/project" + records := []HookRecord{ + { + ID: "codex/pre-tool-use/bash.yaml", + RelativePath: "codex/pre-tool-use/bash.yaml", + Tool: "codex", + Event: "PreToolUse", + Matcher: "Bash", + Handlers: []HookHandler{ + {Type: "command", Command: "./bin/check"}, + {Type: "http", URL: "https://example.com/hook"}, + {Type: "prompt", Prompt: "Summarize the tool input"}, + }, + }, + } + + files, warnings, err := CompileCodexHooks(records, projectRoot, "") + if err != nil { + t.Fatalf("CompileCodexHooks() error = %v", err) + } + if len(warnings) == 0 { + t.Fatal("expected warning for unsupported handlers") + } + + compiled := findHookCompiledFile(t, files, filepath.Join(projectRoot, ".codex", "hooks.json")) + if strings.Contains(compiled.Content, `"type":"http"`) || strings.Contains(compiled.Content, `"type":"prompt"`) { + t.Fatalf("unsupported handlers leaked into codex output: %q", compiled.Content) + } + if !strings.Contains(compiled.Content, `"type":"command"`) { + t.Fatalf("codex output missing command handler: %q", compiled.Content) + } +} + +func TestCompileCodexHooks_SkipsUnsupportedEvents(t *testing.T) { + projectRoot := "/tmp/project" + records := []HookRecord{ + { + ID: "codex/file-changed/bash.yaml", + RelativePath: "codex/file-changed/bash.yaml", + Tool: "codex", + Event: "FileChanged", + Matcher: "Bash", + Handlers: []HookHandler{{Type: "command", Command: "./bin/check"}}, + }, + { + ID: "codex/pre-tool-use/bash.yaml", + RelativePath: "codex/pre-tool-use/bash.yaml", + Tool: "codex", + Event: "PreToolUse", + Matcher: "Bash", + Handlers: []HookHandler{{Type: "command", Command: "./bin/ok"}}, + }, + } + + files, warnings, err := CompileCodexHooks(records, projectRoot, "") + if err != nil { + t.Fatalf("CompileCodexHooks() error = %v", err) + } + if len(warnings) == 0 { + t.Fatal("expected warning for unsupported codex event") + } + + compiled := findHookCompiledFile(t, files, filepath.Join(projectRoot, ".codex", "hooks.json")) + if strings.Contains(compiled.Content, "FileChanged") { + t.Fatalf("unsupported codex event leaked into compiled output: %q", compiled.Content) + } + if !strings.Contains(compiled.Content, "PreToolUse") { + t.Fatalf("supported codex event missing from compiled output: %q", compiled.Content) + } +} + +func TestCompileGeminiHooks_WritesSettingsJSON(t *testing.T) { + projectRoot := "/tmp/project" + sequential := true + records := []HookRecord{ + { + ID: "gemini/before-tool/read.yaml", + RelativePath: "gemini/before-tool/read.yaml", + Tool: "gemini", + Event: "BeforeTool", + Matcher: "Read", + Sequential: &sequential, + Handlers: []HookHandler{{ + Type: "command", + Name: "lint-read", + Description: "Run read lint", + Command: "./bin/gemini-lint", + Timeout: "30000", + }}, + }, + } + + files, warnings, err := CompileGeminiHooks(records, projectRoot, `{"theme":"light"}`) + if err != nil { + t.Fatalf("CompileGeminiHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompileGeminiHooks() warnings = %v, want none", warnings) + } + + compiled := findHookCompiledFile(t, files, filepath.Join(projectRoot, ".gemini", "settings.json")) + for _, want := range []string{ + `"theme":"light"`, + `"BeforeTool"`, + `"matcher":"Read"`, + `"sequential":true`, + `"type":"command"`, + `"name":"lint-read"`, + `"description":"Run read lint"`, + `"command":"./bin/gemini-lint"`, + `"timeout":30000`, + } { + if !strings.Contains(compiled.Content, want) { + t.Fatalf("compiled content missing %q: %q", want, compiled.Content) + } + } +} + +func TestCompileGeminiHooks_SkipsUnsupportedEventsAndHandlers(t *testing.T) { + projectRoot := "/tmp/project" + records := []HookRecord{ + { + ID: "gemini/before-tool/read.yaml", + RelativePath: "gemini/before-tool/read.yaml", + Tool: "gemini", + Event: "BeforeTool", + Matcher: "Read", + Handlers: []HookHandler{ + {Type: "command", Command: "./bin/gemini-lint"}, + {Type: "http", URL: "https://example.com/hook"}, + }, + }, + { + ID: "gemini/file-changed/read.yaml", + RelativePath: "gemini/file-changed/read.yaml", + Tool: "gemini", + Event: "FileChanged", + Matcher: "Read", + Handlers: []HookHandler{{Type: "command", Command: "./bin/skip"}}, + }, + } + + files, warnings, err := CompileGeminiHooks(records, projectRoot, "") + if err != nil { + t.Fatalf("CompileGeminiHooks() error = %v", err) + } + if len(warnings) == 0 { + t.Fatal("expected warnings for unsupported gemini hook content") + } + + compiled := findHookCompiledFile(t, files, filepath.Join(projectRoot, ".gemini", "settings.json")) + if strings.Contains(compiled.Content, `"type":"http"`) { + t.Fatalf("unsupported gemini handler leaked into output: %q", compiled.Content) + } + if strings.Contains(compiled.Content, "FileChanged") { + t.Fatalf("unsupported gemini event leaked into output: %q", compiled.Content) + } + if !strings.Contains(compiled.Content, "BeforeTool") { + t.Fatalf("supported gemini event missing from output: %q", compiled.Content) + } +} + +func TestCompileGeminiHooks_UsesTimeoutSecondsWhenTimeoutStringInvalid(t *testing.T) { + projectRoot := "/tmp/project" + timeoutMillis := 30000 + records := []HookRecord{ + { + ID: "gemini/before-tool/read.yaml", + RelativePath: "gemini/before-tool/read.yaml", + Tool: "gemini", + Event: "BeforeTool", + Matcher: "Read", + Handlers: []HookHandler{{ + Type: "command", + Command: "./bin/gemini-lint", + Timeout: "30s", + TimeoutSeconds: &timeoutMillis, + }}, + }, + } + + files, warnings, err := CompileGeminiHooks(records, projectRoot, "") + if err != nil { + t.Fatalf("CompileGeminiHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompileGeminiHooks() warnings = %v, want none", warnings) + } + + compiled := findHookCompiledFile(t, files, filepath.Join(projectRoot, ".gemini", "settings.json")) + if !strings.Contains(compiled.Content, `"timeout":30000`) { + t.Fatalf("compiled content = %q, want timeout from timeoutSec fallback", compiled.Content) + } +} + +func findHookCompiledFile(t *testing.T, files []CompiledFile, wantPath string) CompiledFile { + t.Helper() + for _, file := range files { + if file.Path == wantPath { + return file + } + } + t.Fatalf("compiled output missing %q", wantPath) + return CompiledFile{} +} + +func intPtr(v int) *int { + return &v +} diff --git a/internal/resources/adapters/pi_rules.go b/internal/resources/adapters/pi_rules.go new file mode 100644 index 00000000..ea9734cd --- /dev/null +++ b/internal/resources/adapters/pi_rules.go @@ -0,0 +1,44 @@ +package adapters + +import ( + "path/filepath" + "sort" + "strings" + + managedpi "skillshare/internal/resources/managed/pi" +) + +// CompilePiRules compiles managed pi rules into target-native files. +func CompilePiRules(records []RuleRecord, projectRoot string) ([]CompiledFile, []string, error) { + sorted := append([]RuleRecord(nil), records...) + sort.Slice(sorted, func(i, j int) bool { + return normalizeRulePath(sorted[i]) < normalizeRulePath(sorted[j]) + }) + + var files []CompiledFile + var warnings []string + for _, record := range sorted { + if strings.TrimSpace(record.Tool) != "" && strings.TrimSpace(record.Tool) != "pi" { + continue + } + + id, ok := managedpi.NormalizeManagedRuleID(normalizeRulePath(record)) + if !ok { + warnings = append(warnings, "unsupported pi rule id: "+normalizeRulePath(record)) + continue + } + + outputPath, ok := managedpi.CompilePath(projectRoot, id) + if !ok { + warnings = append(warnings, "unsupported pi rule id: "+id) + continue + } + files = append(files, CompiledFile{ + Path: filepath.Clean(outputPath), + Content: record.Content, + Format: "markdown", + }) + } + + return files, warnings, nil +} diff --git a/internal/resources/adapters/rules_test.go b/internal/resources/adapters/rules_test.go new file mode 100644 index 00000000..2fbcf45a --- /dev/null +++ b/internal/resources/adapters/rules_test.go @@ -0,0 +1,212 @@ +package adapters + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestCompileClaudeRules_InstructionAndAdditionalRules(t *testing.T) { + projectRoot := "/tmp/project" + records := []RuleRecord{ + {ID: "claude/CLAUDE.md", Tool: "claude", RelativePath: "claude/CLAUDE.md", Name: "CLAUDE.md", Content: "# Claude Root\n"}, + {ID: "claude/backend.md", Tool: "claude", RelativePath: "claude/backend.md", Name: "backend.md", Content: "# Backend\n"}, + } + + files, warnings, err := CompileClaudeRules(records, projectRoot) + if err != nil { + t.Fatalf("CompileClaudeRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompileClaudeRules() warnings = %v, want none", warnings) + } + + _ = findCompiledFile(t, files, filepath.Join(projectRoot, "CLAUDE.md")) + rule := findCompiledFile(t, files, filepath.Join(projectRoot, ".claude", "rules", "backend.md")) + if !strings.Contains(rule.Content, "# Backend") { + t.Fatalf("compiled backend rule content = %q, want to include backend markdown", rule.Content) + } +} + +func TestCompileClaudeRules_GlobalConfigRootUsesRulesSubdir(t *testing.T) { + globalRoot := "/tmp/home/.claude" + records := []RuleRecord{ + {ID: "claude/CLAUDE.md", Tool: "claude", RelativePath: "claude/CLAUDE.md", Name: "CLAUDE.md", Content: "# Claude Root\n"}, + {ID: "claude/backend.md", Tool: "claude", RelativePath: "claude/backend.md", Name: "backend.md", Content: "# Backend\n"}, + } + + files, warnings, err := CompileClaudeRules(records, globalRoot) + if err != nil { + t.Fatalf("CompileClaudeRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompileClaudeRules() warnings = %v, want none", warnings) + } + + _ = findCompiledFile(t, files, filepath.Join(globalRoot, "CLAUDE.md")) + _ = findCompiledFile(t, files, filepath.Join(globalRoot, "rules", "backend.md")) + mustNotContainCompiledFile(t, files, filepath.Join(globalRoot, ".claude", "rules", "backend.md")) +} + +func TestCompileCodexRules_AggregatesWithMarkers(t *testing.T) { + projectRoot := "/tmp/project" + records := []RuleRecord{ + {ID: "codex/AGENTS.md", Tool: "codex", RelativePath: "codex/AGENTS.md", Name: "AGENTS.md", Content: "# Root\n"}, + {ID: "codex/backend.md", Tool: "codex", RelativePath: "codex/backend.md", Name: "backend.md", Content: "# Backend\n"}, + } + + files, warnings, err := CompileCodexRules(records, projectRoot) + if err != nil { + t.Fatalf("CompileCodexRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompileCodexRules() warnings = %v, want none", warnings) + } + + compiled := findCompiledFile(t, files, filepath.Join(projectRoot, "AGENTS.md")) + if !strings.Contains(compiled.Content, "") { + t.Fatalf("compiled AGENTS missing source marker for backend; content = %q", compiled.Content) + } +} + +func TestCompileGeminiRules_InstructionAndAdditionalRules(t *testing.T) { + projectRoot := "/tmp/project" + records := []RuleRecord{ + {ID: "gemini/GEMINI.md", Tool: "gemini", RelativePath: "gemini/GEMINI.md", Name: "GEMINI.md", Content: "# Gemini Root\n"}, + {ID: "gemini/backend.md", Tool: "gemini", RelativePath: "gemini/backend.md", Name: "backend.md", Content: "# Backend\n"}, + } + + files, warnings, err := CompileGeminiRules(records, projectRoot) + if err != nil { + t.Fatalf("CompileGeminiRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompileGeminiRules() warnings = %v, want none", warnings) + } + + _ = findCompiledFile(t, files, filepath.Join(projectRoot, "GEMINI.md")) + _ = findCompiledFile(t, files, filepath.Join(projectRoot, ".gemini", "rules", "backend.md")) +} + +func TestCompileGeminiRules_GlobalConfigRootUsesRulesSubdir(t *testing.T) { + globalRoot := "/tmp/home/.gemini" + records := []RuleRecord{ + {ID: "gemini/GEMINI.md", Tool: "gemini", RelativePath: "gemini/GEMINI.md", Name: "GEMINI.md", Content: "# Gemini Root\n"}, + {ID: "gemini/backend.md", Tool: "gemini", RelativePath: "gemini/backend.md", Name: "backend.md", Content: "# Backend\n"}, + } + + files, warnings, err := CompileGeminiRules(records, globalRoot) + if err != nil { + t.Fatalf("CompileGeminiRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompileGeminiRules() warnings = %v, want none", warnings) + } + + _ = findCompiledFile(t, files, filepath.Join(globalRoot, "GEMINI.md")) + _ = findCompiledFile(t, files, filepath.Join(globalRoot, "rules", "backend.md")) + mustNotContainCompiledFile(t, files, filepath.Join(globalRoot, ".gemini", "rules", "backend.md")) +} + +func TestCompilePiRules_WritesInstructionSurfaces(t *testing.T) { + projectRoot := "/tmp/project" + records := []RuleRecord{ + {ID: "pi/AGENTS.md", Tool: "pi", RelativePath: "pi/AGENTS.md", Name: "AGENTS.md", Content: "# Pi Root\n"}, + {ID: "pi/SYSTEM.md", Tool: "pi", RelativePath: "pi/SYSTEM.md", Name: "SYSTEM.md", Content: "# Pi System\n"}, + {ID: "pi/APPEND_SYSTEM.md", Tool: "pi", RelativePath: "pi/APPEND_SYSTEM.md", Name: "APPEND_SYSTEM.md", Content: "# Pi Append\n"}, + } + + files, warnings, err := CompilePiRules(records, projectRoot) + if err != nil { + t.Fatalf("CompilePiRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompilePiRules() warnings = %v, want none", warnings) + } + + _ = findCompiledFile(t, files, filepath.Join(projectRoot, "AGENTS.md")) + _ = findCompiledFile(t, files, filepath.Join(projectRoot, ".pi", "SYSTEM.md")) + _ = findCompiledFile(t, files, filepath.Join(projectRoot, ".pi", "APPEND_SYSTEM.md")) +} + +func TestCompilePiRules_WritesNestedAgentsFilesToMatchingProjectPath(t *testing.T) { + projectRoot := "/tmp/project" + records := []RuleRecord{ + {ID: "pi/nested/AGENTS.md", Tool: "pi", RelativePath: "pi/nested/AGENTS.md", Name: "AGENTS.md", Content: "# Nested Pi\n"}, + } + + files, warnings, err := CompilePiRules(records, projectRoot) + if err != nil { + t.Fatalf("CompilePiRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompilePiRules() warnings = %v, want none", warnings) + } + + compiled := findCompiledFile(t, files, filepath.Join(projectRoot, "nested", "AGENTS.md")) + if !strings.Contains(compiled.Content, "# Nested Pi") { + t.Fatalf("compiled nested agents content = %q, want nested pi markdown", compiled.Content) + } +} + +func TestCompilePiRules_GlobalConfigRootUsesAgentSubdir(t *testing.T) { + globalRoot := "/tmp/home/.pi/agent" + records := []RuleRecord{ + {ID: "pi/SYSTEM.md", Tool: "pi", RelativePath: "pi/SYSTEM.md", Name: "SYSTEM.md", Content: "# Pi System\n"}, + {ID: "pi/APPEND_SYSTEM.md", Tool: "pi", RelativePath: "pi/APPEND_SYSTEM.md", Name: "APPEND_SYSTEM.md", Content: "# Pi Append\n"}, + } + + files, warnings, err := CompilePiRules(records, globalRoot) + if err != nil { + t.Fatalf("CompilePiRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompilePiRules() warnings = %v, want none", warnings) + } + + _ = findCompiledFile(t, files, filepath.Join(globalRoot, "SYSTEM.md")) + _ = findCompiledFile(t, files, filepath.Join(globalRoot, "APPEND_SYSTEM.md")) + mustNotContainCompiledFile(t, files, filepath.Join(globalRoot, ".pi", "SYSTEM.md")) + mustNotContainCompiledFile(t, files, filepath.Join(globalRoot, ".pi", "APPEND_SYSTEM.md")) +} + +func TestCompilePiRules_WarnsOnUnsupportedPiRuleIDs(t *testing.T) { + projectRoot := "/tmp/project" + records := []RuleRecord{ + {ID: "pi/SYSTEM.md", Tool: "pi", RelativePath: "pi/SYSTEM.md", Name: "SYSTEM.md", Content: "# Pi System\n"}, + {ID: "pi/extra.md", Tool: "pi", RelativePath: "pi/extra.md", Name: "extra.md", Content: "# Extra\n"}, + } + + files, warnings, err := CompilePiRules(records, projectRoot) + if err != nil { + t.Fatalf("CompilePiRules() error = %v", err) + } + + _ = findCompiledFile(t, files, filepath.Join(projectRoot, ".pi", "SYSTEM.md")) + if len(warnings) != 1 { + t.Fatalf("CompilePiRules() warnings = %v, want one warning", warnings) + } + if !strings.Contains(warnings[0], "pi/extra.md") { + t.Fatalf("CompilePiRules() warnings = %v, want unsupported pi rule id warning", warnings) + } +} + +func findCompiledFile(t *testing.T, files []CompiledFile, wantPath string) CompiledFile { + t.Helper() + for _, file := range files { + if file.Path == wantPath { + return file + } + } + t.Fatalf("compiled output missing %q", wantPath) + return CompiledFile{} +} + +func mustNotContainCompiledFile(t *testing.T, files []CompiledFile, wantPath string) { + t.Helper() + for _, file := range files { + if file.Path == wantPath { + t.Fatalf("compiled output unexpectedly contained %q", wantPath) + } + } +} diff --git a/internal/resources/adapters/types.go b/internal/resources/adapters/types.go new file mode 100644 index 00000000..10e59494 --- /dev/null +++ b/internal/resources/adapters/types.go @@ -0,0 +1,41 @@ +package adapters + +// CompiledFile is one target-native file generated from managed resources. +type CompiledFile struct { + Path string `json:"path"` + Content string `json:"content"` + Format string `json:"format"` +} + +// RuleRecord is an adapter-friendly view over one managed rule record. +type RuleRecord struct { + ID string + Tool string + RelativePath string + Name string + Content string +} + +// HookRecord is an adapter-friendly view over one managed hook record. +type HookRecord struct { + ID string + Tool string + RelativePath string + Event string + Matcher string + Sequential *bool + Handlers []HookHandler +} + +// HookHandler is one action within a managed hook record. +type HookHandler struct { + Type string + Name string + Description string + Command string + URL string + Prompt string + Timeout string + TimeoutSeconds *int + StatusMessage string +} diff --git a/internal/resources/apply/files.go b/internal/resources/apply/files.go new file mode 100644 index 00000000..edd7b87a --- /dev/null +++ b/internal/resources/apply/files.go @@ -0,0 +1,96 @@ +package apply + +import ( + "errors" + "os" + "path/filepath" + "sort" + + "skillshare/internal/resources/adapters" +) + +var writeFileAtomically = WriteFileAtomic + +// CompiledFiles writes compiled resource files in a stable order and reports +// which files changed versus already matched the compiled content. +func CompiledFiles(files []adapters.CompiledFile, dryRun bool) ([]string, []string, error) { + if files == nil { + files = []adapters.CompiledFile{} + } + + sorted := append([]adapters.CompiledFile(nil), files...) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Path < sorted[j].Path + }) + + updated := make([]string, 0, len(sorted)) + skipped := make([]string, 0, len(sorted)) + for _, file := range sorted { + same, err := compiledFileMatches(file.Path, file.Content) + if err != nil { + return nil, nil, err + } + if same { + skipped = append(skipped, file.Path) + continue + } + + updated = append(updated, file.Path) + if dryRun { + continue + } + if err := writeFileAtomically(file.Path, []byte(file.Content), 0o644); err != nil { + return nil, nil, err + } + } + + return updated, skipped, nil +} + +// WriteFileAtomic writes a file by staging a temp file in the same directory +// and renaming it into place. The destination is either fully updated or left +// unchanged. +func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + tempFile, err := os.CreateTemp(filepath.Dir(path), ".compiled-tmp-*") + if err != nil { + return err + } + + tempPath := tempFile.Name() + cleanupWith := func(writeErr error) error { + _ = tempFile.Close() + _ = os.Remove(tempPath) + return writeErr + } + + if _, err := tempFile.Write(data); err != nil { + return cleanupWith(err) + } + if err := tempFile.Chmod(perm); err != nil { + return cleanupWith(err) + } + if err := tempFile.Close(); err != nil { + return cleanupWith(err) + } + if err := replaceFile(tempPath, path); err != nil { + _ = os.Remove(tempPath) + return err + } + + return nil +} + +func compiledFileMatches(path, content string) (bool, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + return string(data) == content, nil +} diff --git a/internal/resources/apply/files_test.go b/internal/resources/apply/files_test.go new file mode 100644 index 00000000..2449119b --- /dev/null +++ b/internal/resources/apply/files_test.go @@ -0,0 +1,78 @@ +package apply + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "skillshare/internal/resources/adapters" +) + +func TestCompiledFiles_SortsUpdatedAndSkipped(t *testing.T) { + dir := t.TempDir() + samePath := filepath.Join(dir, "a.json") + updatePath := filepath.Join(dir, "b.json") + + if err := os.WriteFile(samePath, []byte("same"), 0o644); err != nil { + t.Fatalf("seed samePath: %v", err) + } + if err := os.WriteFile(updatePath, []byte("old"), 0o644); err != nil { + t.Fatalf("seed updatePath: %v", err) + } + + updated, skipped, err := CompiledFiles([]adapters.CompiledFile{ + {Path: updatePath, Content: "new"}, + {Path: samePath, Content: "same"}, + }, false) + if err != nil { + t.Fatalf("CompiledFiles() error = %v", err) + } + + if !reflect.DeepEqual(updated, []string{updatePath}) { + t.Fatalf("updated = %v, want [%s]", updated, updatePath) + } + if !reflect.DeepEqual(skipped, []string{samePath}) { + t.Fatalf("skipped = %v, want [%s]", skipped, samePath) + } + + got, err := os.ReadFile(updatePath) + if err != nil { + t.Fatalf("read updated path: %v", err) + } + if string(got) != "new" { + t.Fatalf("updated file content = %q, want %q", string(got), "new") + } +} + +func TestCompiledFiles_LeavesDestinationUntouchedWhenAtomicWriteFails(t *testing.T) { + dir := t.TempDir() + targetPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(targetPath, []byte("original"), 0o644); err != nil { + t.Fatalf("seed target: %v", err) + } + + originalWriter := writeFileAtomically + writeFileAtomically = func(path string, data []byte, perm os.FileMode) error { + return errors.New("boom") + } + t.Cleanup(func() { + writeFileAtomically = originalWriter + }) + + _, _, err := CompiledFiles([]adapters.CompiledFile{ + {Path: targetPath, Content: "replacement"}, + }, false) + if err == nil { + t.Fatal("CompiledFiles() error = nil, want write failure") + } + + got, readErr := os.ReadFile(targetPath) + if readErr != nil { + t.Fatalf("read target after failure: %v", readErr) + } + if string(got) != "original" { + t.Fatalf("target content after failed write = %q, want %q", string(got), "original") + } +} diff --git a/internal/resources/apply/replace_nonwindows.go b/internal/resources/apply/replace_nonwindows.go new file mode 100644 index 00000000..f56ceaaa --- /dev/null +++ b/internal/resources/apply/replace_nonwindows.go @@ -0,0 +1,9 @@ +//go:build !windows + +package apply + +import "os" + +func replaceFile(tempPath, fullPath string) error { + return os.Rename(tempPath, fullPath) +} diff --git a/internal/resources/apply/replace_windows.go b/internal/resources/apply/replace_windows.go new file mode 100644 index 00000000..d5db6a3c --- /dev/null +++ b/internal/resources/apply/replace_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package apply + +import "golang.org/x/sys/windows" + +func replaceFile(tempPath, fullPath string) error { + return windows.Rename(tempPath, fullPath) +} diff --git a/internal/resources/hooks/collect.go b/internal/resources/hooks/collect.go new file mode 100644 index 00000000..37383c82 --- /dev/null +++ b/internal/resources/hooks/collect.go @@ -0,0 +1,448 @@ +package hooks + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path" + "sort" + "strings" + + "skillshare/internal/inspect" +) + +type Strategy string + +const ( + StrategySkip Strategy = "skip" + StrategyOverwrite Strategy = "overwrite" + StrategyDuplicate Strategy = "duplicate" +) + +type CollectOptions struct { + Strategy Strategy +} + +type CollectResult struct { + Created []string + Overwritten []string + Skipped []string +} + +type collectedHookGroup struct { + GroupID string + SourceTool string + Scope inspect.Scope + Event string + Matcher string + Sequential *bool + Path string + Collectible bool + CollectReason string + Items []inspect.HookItem +} + +type collectAppliedWrite struct { + id string + hadPrior bool + priorRecord Record +} + +type collectPlan struct { + result CollectResult + writes []Save +} + +// PreviewCollect validates discovered hooks and reports what Collect would do. +func PreviewCollect(projectRoot string, discovered []inspect.HookItem, opts CollectOptions) (CollectResult, error) { + strategy, err := normalizeStrategy(opts.Strategy) + if err != nil { + return CollectResult{}, err + } + + store := NewStore(projectRoot) + existing, err := store.List() + if err != nil { + return CollectResult{}, err + } + + plan, err := planCollect(existing, discovered, strategy) + if err != nil { + return CollectResult{}, err + } + return plan.result, nil +} + +// Collect imports discovered hook files into managed hook storage. +func Collect(projectRoot string, discovered []inspect.HookItem, opts CollectOptions) (CollectResult, error) { + strategy, err := normalizeStrategy(opts.Strategy) + if err != nil { + return CollectResult{}, err + } + + store := NewStore(projectRoot) + existing, err := store.List() + if err != nil { + return CollectResult{}, err + } + + existingByID := make(map[string]Record, len(existing)) + for _, record := range existing { + existingByID[record.ID] = record + } + + plan, err := planCollect(existing, discovered, strategy) + if err != nil { + return CollectResult{}, err + } + + applied := make([]collectAppliedWrite, 0, len(plan.writes)) + for _, write := range plan.writes { + prior, hadPrior := existingByID[write.ID] + applied = append(applied, collectAppliedWrite{ + id: write.ID, + hadPrior: hadPrior, + priorRecord: prior, + }) + + record, err := store.Put(write) + if err != nil { + rollbackErr := rollbackAppliedHookWrites(store, applied[:len(applied)-1]) + if rollbackErr != nil { + return CollectResult{}, fmt.Errorf("apply collected hooks: %w; rollback failed: %v", err, rollbackErr) + } + return CollectResult{}, err + } + existingByID[write.ID] = record + } + + return plan.result, nil +} + +func planCollect(existing []Record, discovered []inspect.HookItem, strategy Strategy) (collectPlan, error) { + takenIDs := make(map[string]bool, len(existing)+len(discovered)) + for _, record := range existing { + takenIDs[record.ID] = true + } + + groups, err := groupInspectHooks(discovered) + if err != nil { + return collectPlan{}, err + } + if err := rejectCanonicalIDCollisions(groups); err != nil { + return collectPlan{}, err + } + + plan := collectPlan{ + result: CollectResult{}, + writes: make([]Save, 0, len(groups)), + } + for _, group := range groups { + if !group.Collectible { + reason := strings.TrimSpace(group.CollectReason) + if reason == "" { + reason = "hook group is not collectible" + } + return collectPlan{}, fmt.Errorf("cannot collect %s: %s", group.GroupID, reason) + } + + id, err := canonicalRelativePath(group.SourceTool, group.Event, group.Matcher) + if err != nil { + return collectPlan{}, err + } + + save := Save{ + ID: id, + Tool: strings.ToLower(strings.TrimSpace(group.SourceTool)), + Event: strings.TrimSpace(group.Event), + Matcher: strings.TrimSpace(group.Matcher), + Sequential: copyOptionalBool(group.Sequential), + Handlers: handlersFromInspectHooks(sortedHookItems(group.Items)), + SourceType: "local", + } + + exists := takenIDs[id] + switch { + case !exists: + plan.writes = append(plan.writes, save) + takenIDs[id] = true + plan.result.Created = append(plan.result.Created, id) + case strategy == StrategySkip: + plan.result.Skipped = append(plan.result.Skipped, id) + case strategy == StrategyOverwrite: + prior := managedHookByID(existing, id) + save.Targets = append([]string(nil), prior.Targets...) + save.SourceType = prior.SourceType + save.Disabled = prior.Disabled + plan.writes = append(plan.writes, save) + plan.result.Overwritten = append(plan.result.Overwritten, id) + case strategy == StrategyDuplicate: + duplicateID := nextDuplicateIDFromTaken(takenIDs, id) + save.ID = duplicateID + plan.writes = append(plan.writes, save) + takenIDs[duplicateID] = true + plan.result.Created = append(plan.result.Created, duplicateID) + } + } + + return plan, nil +} + +func groupInspectHooks(discovered []inspect.HookItem) ([]collectedHookGroup, error) { + groups := make(map[string]*collectedHookGroup) + order := make([]string, 0, len(discovered)) + + for _, item := range discovered { + groupID := strings.TrimSpace(item.GroupID) + if groupID == "" { + return nil, fmt.Errorf("cannot collect hook with empty group id") + } + if strings.TrimSpace(item.SourceTool) == "" { + return nil, fmt.Errorf("cannot collect %s: missing source tool", groupID) + } + if strings.TrimSpace(item.Event) == "" { + return nil, fmt.Errorf("cannot collect %s: missing event", groupID) + } + if strings.TrimSpace(item.Matcher) == "" && !managedHookAllowsEmptyMatcher(strings.TrimSpace(item.SourceTool), strings.TrimSpace(item.Event)) { + return nil, fmt.Errorf("cannot collect %s: missing matcher", groupID) + } + + group, ok := groups[groupID] + if !ok { + copy := collectedHookGroup{ + GroupID: groupID, + SourceTool: strings.TrimSpace(item.SourceTool), + Scope: item.Scope, + Event: strings.TrimSpace(item.Event), + Matcher: strings.TrimSpace(item.Matcher), + Sequential: copyOptionalBool(item.Sequential), + Path: strings.TrimSpace(item.Path), + Collectible: item.Collectible, + CollectReason: strings.TrimSpace(item.CollectReason), + Items: []inspect.HookItem{item}, + } + groups[groupID] = © + order = append(order, groupID) + continue + } + + if group.SourceTool != strings.TrimSpace(item.SourceTool) || group.Event != strings.TrimSpace(item.Event) || group.Matcher != strings.TrimSpace(item.Matcher) { + return nil, fmt.Errorf("cannot collect %s: hook items disagree on source tool, event, or matcher", groupID) + } + if !optionalBoolsEqual(group.Sequential, item.Sequential) { + return nil, fmt.Errorf("cannot collect %s: hook items disagree on sequential", groupID) + } + if group.Path != strings.TrimSpace(item.Path) { + return nil, fmt.Errorf("cannot collect %s: hook items disagree on source path", groupID) + } + if group.Collectible != item.Collectible || group.CollectReason != strings.TrimSpace(item.CollectReason) { + return nil, fmt.Errorf("cannot collect %s: hook items disagree on collectibility", groupID) + } + group.Items = append(group.Items, item) + } + + out := make([]collectedHookGroup, 0, len(order)) + for _, groupID := range order { + group := groups[groupID] + if len(group.Items) == 0 { + return nil, fmt.Errorf("cannot collect %s: missing hook actions", group.GroupID) + } + out = append(out, *group) + } + return out, nil +} + +func rejectCanonicalIDCollisions(groups []collectedHookGroup) error { + byID := make(map[string]collectedHookGroup, len(groups)) + for _, group := range groups { + id, err := canonicalRelativePath(group.SourceTool, group.Event, group.Matcher) + if err != nil { + return err + } + if prior, ok := byID[id]; ok && prior.GroupID != group.GroupID { + return fmt.Errorf("cannot collect %s and %s: canonical managed id %q collides", prior.GroupID, group.GroupID, id) + } + byID[id] = group + } + return nil +} + +func handlersFromInspectHooks(items []inspect.HookItem) []Handler { + if len(items) == 0 { + return nil + } + handlers := make([]Handler, 0, len(items)) + for _, item := range items { + handlers = append(handlers, Handler{ + Type: strings.TrimSpace(item.ActionType), + Name: strings.TrimSpace(item.Name), + Description: strings.TrimSpace(item.Description), + Command: strings.TrimSpace(item.Command), + URL: strings.TrimSpace(item.URL), + Prompt: strings.TrimSpace(item.Prompt), + Timeout: strings.TrimSpace(item.Timeout), + TimeoutSeconds: item.TimeoutSeconds, + StatusMessage: strings.TrimSpace(item.StatusMessage), + }) + } + return handlers +} + +func sortedHookItems(items []inspect.HookItem) []inspect.HookItem { + if len(items) < 2 { + return append([]inspect.HookItem(nil), items...) + } + + sorted := append([]inspect.HookItem(nil), items...) + sort.SliceStable(sorted, func(i, j int) bool { + if sorted[i].EntryIndex != sorted[j].EntryIndex { + return sorted[i].EntryIndex < sorted[j].EntryIndex + } + return sorted[i].ActionIndex < sorted[j].ActionIndex + }) + return sorted +} + +func rollbackAppliedHookWrites(store *Store, applied []collectAppliedWrite) error { + var firstErr error + for i := len(applied) - 1; i >= 0; i-- { + entry := applied[i] + if entry.hadPrior { + if _, err := store.Put(Save{ + ID: entry.priorRecord.ID, + Tool: entry.priorRecord.Tool, + Event: entry.priorRecord.Event, + Matcher: entry.priorRecord.Matcher, + Sequential: copyOptionalBool(entry.priorRecord.Sequential), + Handlers: entry.priorRecord.Handlers, + Targets: append([]string(nil), entry.priorRecord.Targets...), + SourceType: entry.priorRecord.SourceType, + Disabled: entry.priorRecord.Disabled, + }); err != nil && firstErr == nil { + firstErr = err + } + continue + } + if err := store.Delete(entry.id); err != nil && !os.IsNotExist(err) && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func optionalBoolsEqual(left, right *bool) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return *left == *right +} + +func managedHookByID(existing []Record, id string) Record { + for _, record := range existing { + if record.ID == id { + return record + } + } + return Record{} +} + +func normalizeStrategy(strategy Strategy) (Strategy, error) { + switch strings.TrimSpace(string(strategy)) { + case "": + return StrategySkip, nil + case string(StrategySkip): + return StrategySkip, nil + case string(StrategyOverwrite): + return StrategyOverwrite, nil + case string(StrategyDuplicate): + return StrategyDuplicate, nil + default: + return "", fmt.Errorf("invalid collect strategy %q", strategy) + } +} + +func nextDuplicateIDFromTaken(taken map[string]bool, id string) string { + ext := path.Ext(id) + base := strings.TrimSuffix(path.Base(id), ext) + dir := path.Dir(id) + if dir == "." { + dir = "" + } + + candidateFor := func(suffix string) string { + name := base + suffix + ext + if dir == "" { + return name + } + return path.Join(dir, name) + } + + first := candidateFor("-copy") + if !taken[first] { + return first + } + + for i := 2; ; i++ { + candidate := candidateFor(fmt.Sprintf("-copy-%d", i)) + if !taken[candidate] { + return candidate + } + } +} + +func canonicalRelativePath(tool, event, matcher string) (string, error) { + cleanTool := sanitizeHookPathSegment(tool) + cleanEvent := sanitizeHookPathSegment(event) + cleanMatcher := matcherIdentitySegment(matcher) + if cleanTool == "" { + return "", fmt.Errorf("cannot collect hook: missing tool") + } + if cleanEvent == "" { + return "", fmt.Errorf("cannot collect hook: missing event") + } + if cleanMatcher == "" { + return "", fmt.Errorf("cannot collect hook: missing matcher") + } + return path.Join(cleanTool, cleanEvent, cleanMatcher+".yaml"), nil +} + +func matcherIdentitySegment(matcher string) string { + raw := strings.TrimSpace(matcher) + slug := sanitizeHookPathSegment(raw) + if slug == "" { + slug = "matcher" + } + sum := sha256.Sum256([]byte(raw)) + return slug + "-" + hex.EncodeToString(sum[:6]) +} + +func sanitizeHookPathSegment(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + var b strings.Builder + needDash := false + for i, r := range value { + switch { + case r >= 'A' && r <= 'Z': + if i > 0 && !needDash { + b.WriteByte('-') + } + b.WriteByte(byte(r + ('a' - 'A'))) + needDash = false + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + needDash = false + default: + if !needDash { + b.WriteByte('-') + needDash = true + } + } + } + + return strings.Trim(b.String(), "-") +} diff --git a/internal/resources/hooks/collect_test.go b/internal/resources/hooks/collect_test.go new file mode 100644 index 00000000..ad0463b2 --- /dev/null +++ b/internal/resources/hooks/collect_test.go @@ -0,0 +1,626 @@ +package hooks + +import ( + "errors" + "os" + "path" + "path/filepath" + "strings" + "testing" + + "skillshare/internal/inspect" +) + +func TestCollectHooks_RejectsPrivateLocalGroups(t *testing.T) { + root := t.TempDir() + discovered := []inspect.HookItem{ + { + GroupID: "claude:project:/tmp/project/.claude/settings.local.json:PreToolUse:Edit", + SourceTool: "claude", + Scope: inspect.ScopeProject, + Event: "PreToolUse", + Matcher: "Edit", + ActionType: "command", + Command: "./bin/local-only", + Path: "/tmp/project/.claude/settings.local.json", + Collectible: false, + CollectReason: "private project-local override files stay diagnostics-only", + }, + } + + _, err := Collect(root, discovered, CollectOptions{Strategy: StrategyOverwrite}) + if err == nil { + t.Fatal("expected collect error for non-collectible local hook group") + } +} + +func TestCollectHooks_PreservesGroupedHandlers(t *testing.T) { + root := t.TempDir() + wantID := mustCanonicalRelativePath(t, "claude", "PreToolUse", "Bash") + discovered := []inspect.HookItem{ + { + GroupID: "claude:project:/tmp/project/.claude/settings.json:PreToolUse:Bash", + SourceTool: "claude", + Scope: inspect.ScopeProject, + Event: "PreToolUse", + Matcher: "Bash", + ActionType: "command", + Command: "./bin/check", + Timeout: "30s", + StatusMessage: "Running check", + Path: "/tmp/project/.claude/settings.json", + Collectible: true, + }, + { + GroupID: "claude:project:/tmp/project/.claude/settings.json:PreToolUse:Bash", + SourceTool: "claude", + Scope: inspect.ScopeProject, + Event: "PreToolUse", + Matcher: "Bash", + ActionType: "prompt", + Prompt: "Summarize the tool input", + Timeout: "15s", + StatusMessage: "Prompting for summary", + Path: "/tmp/project/.claude/settings.json", + Collectible: true, + }, + } + + result, err := Collect(root, discovered, CollectOptions{Strategy: StrategyOverwrite}) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + if len(result.Created) != 1 { + t.Fatalf("Collect() Created = %v, want one record", result.Created) + } + if result.Created[0] != wantID { + t.Fatalf("Collect() Created[0] = %q, want %q", result.Created[0], wantID) + } + + store := NewStore(root) + got, err := store.Get(wantID) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if len(got.Handlers) != 2 { + t.Fatalf("Get() handlers len = %d, want 2", len(got.Handlers)) + } + if got.Handlers[0].Type != "command" || got.Handlers[0].Command != "./bin/check" { + t.Fatalf("first handler = %#v, want command handler", got.Handlers[0]) + } + if got.Handlers[0].Timeout != "30s" || got.Handlers[0].StatusMessage != "Running check" { + t.Fatalf("first handler metadata = %#v, want timeout/statusMessage preserved", got.Handlers[0]) + } + if got.Handlers[1].Type != "prompt" || got.Handlers[1].Prompt != "Summarize the tool input" { + t.Fatalf("second handler = %#v, want prompt handler", got.Handlers[1]) + } + if got.Handlers[1].Timeout != "15s" || got.Handlers[1].StatusMessage != "Prompting for summary" { + t.Fatalf("second handler metadata = %#v, want timeout/statusMessage preserved", got.Handlers[1]) + } + if got.Targets != nil { + t.Fatalf("Get() targets = %v, want nil", got.Targets) + } + if got.SourceType != "local" { + t.Fatalf("Get() sourceType = %q, want %q", got.SourceType, "local") + } + if got.Disabled { + t.Fatalf("Get() disabled = %v, want false", got.Disabled) + } +} + +func TestCollectHooks_OverwritePreservesExistingMetadata(t *testing.T) { + root := t.TempDir() + store := NewStore(root) + id := mustCanonicalRelativePath(t, "claude", "PreToolUse", "Bash") + + _, err := store.Put(Save{ + ID: id, + Tool: "claude", + Event: "PreToolUse", + Matcher: "Bash", + Targets: []string{"claude-work"}, + SourceType: "tracked", + Disabled: true, + Handlers: []Handler{{ + Type: "command", + Command: "./bin/original", + }}, + }) + if err != nil { + t.Fatalf("seed Put() error = %v", err) + } + + discovered := []inspect.HookItem{{ + GroupID: "claude:project:/tmp/project/.claude/settings.json:PreToolUse:Bash", + SourceTool: "claude", + Scope: inspect.ScopeProject, + Event: "PreToolUse", + Matcher: "Bash", + ActionType: "command", + Command: "./bin/updated", + Path: "/tmp/project/.claude/settings.json", + Collectible: true, + }} + + result, err := Collect(root, discovered, CollectOptions{Strategy: StrategyOverwrite}) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + if len(result.Overwritten) != 1 || result.Overwritten[0] != id { + t.Fatalf("Collect() Overwritten = %v, want [%s]", result.Overwritten, id) + } + + got, err := store.Get(id) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if len(got.Handlers) != 1 || got.Handlers[0].Command != "./bin/updated" { + t.Fatalf("Get() handlers = %#v, want updated handler", got.Handlers) + } + if len(got.Targets) != 1 || got.Targets[0] != "claude-work" { + t.Fatalf("Get() targets = %v, want [claude-work]", got.Targets) + } + if got.SourceType != "tracked" { + t.Fatalf("Get() sourceType = %q, want %q", got.SourceType, "tracked") + } + if !got.Disabled { + t.Fatalf("Get() disabled = %v, want true", got.Disabled) + } +} + +func TestCollectHooks_PreservesDiscoveredHandlerOrder(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + wantID := mustCanonicalRelativePath(t, "claude", "PreToolUse", "Bash") + + if err := os.MkdirAll(filepath.Join(project, ".claude"), 0755); err != nil { + t.Fatalf("mkdir hook config dir error = %v", err) + } + if err := os.WriteFile(filepath.Join(project, ".claude", "settings.json"), []byte(`{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"http","url":"https://example.com/hook","statusMessage":"Sending webhook"},{"type":"command","command":"./bin/first","timeout":"30s","statusMessage":"First command"},{"type":"prompt","prompt":"Summarize the tool input","timeout":"15s","statusMessage":"Prompting for summary"},{"type":"command","command":"./bin/second","timeout":"45s","statusMessage":"Second command"}]}]}}`), 0644); err != nil { + t.Fatalf("write hook config error = %v", err) + } + + t.Setenv("HOME", home) + + discovered, warnings, err := inspect.ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(discovered) != 4 { + t.Fatalf("expected 4 discovered hook items, got %d", len(discovered)) + } + shuffled := []inspect.HookItem{discovered[2], discovered[0], discovered[3], discovered[1]} + result, err := Collect(project, shuffled, CollectOptions{Strategy: StrategyOverwrite}) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + if len(result.Created) != 1 || result.Created[0] != wantID { + t.Fatalf("Collect() Created = %v, want [%s]", result.Created, wantID) + } + + store := NewStore(project) + got, err := store.Get(wantID) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if len(got.Handlers) != 4 { + t.Fatalf("Get() handlers len = %d, want 4", len(got.Handlers)) + } + if got.Handlers[0].Type != "http" || got.Handlers[1].Command != "./bin/first" || got.Handlers[2].Type != "prompt" || got.Handlers[3].Command != "./bin/second" { + t.Fatalf("handlers order was not preserved: %#v", got.Handlers) + } +} + +func TestCollectHooks_PreservesDuplicateEntryOrder(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + wantID := mustCanonicalRelativePath(t, "claude", "PreToolUse", "Bash") + + if err := os.MkdirAll(filepath.Join(project, ".claude"), 0755); err != nil { + t.Fatalf("mkdir hook config dir error = %v", err) + } + if err := os.WriteFile(filepath.Join(project, ".claude", "settings.json"), []byte(`{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./bin/entry-one-a"},{"type":"command","command":"./bin/entry-one-b"}]},{"matcher":"Bash","hooks":[{"type":"command","command":"./bin/entry-two-a"},{"type":"command","command":"./bin/entry-two-b"}]}]}}`), 0644); err != nil { + t.Fatalf("write hook config error = %v", err) + } + + t.Setenv("HOME", home) + + discovered, warnings, err := inspect.ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(discovered) != 4 { + t.Fatalf("expected 4 discovered hook items, got %d", len(discovered)) + } + + shuffled := []inspect.HookItem{ + hookItemWithCommand(t, discovered, "./bin/entry-two-b"), + hookItemWithCommand(t, discovered, "./bin/entry-one-a"), + hookItemWithCommand(t, discovered, "./bin/entry-two-a"), + hookItemWithCommand(t, discovered, "./bin/entry-one-b"), + } + + result, err := Collect(project, shuffled, CollectOptions{Strategy: StrategyOverwrite}) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + if len(result.Created) != 1 || result.Created[0] != wantID { + t.Fatalf("Collect() Created = %v, want [%s]", result.Created, wantID) + } + + store := NewStore(project) + got, err := store.Get(wantID) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + wantCommands := []string{"./bin/entry-one-a", "./bin/entry-one-b", "./bin/entry-two-a", "./bin/entry-two-b"} + if len(got.Handlers) != len(wantCommands) { + t.Fatalf("Get() handlers len = %d, want %d", len(got.Handlers), len(wantCommands)) + } + for i, wantCommand := range wantCommands { + if got.Handlers[i].Command != wantCommand { + t.Fatalf("Get() Handlers[%d].Command = %q, want %q; handlers=%#v", i, got.Handlers[i].Command, wantCommand, got.Handlers) + } + } +} + +func TestCollectHooks_RejectsCanonicalIDCollisionsAcrossSources(t *testing.T) { + root := t.TempDir() + discovered := []inspect.HookItem{ + { + GroupID: "claude:project:/tmp/project/.claude/settings.json:PreToolUse:Bash", + SourceTool: "claude", + Scope: inspect.ScopeProject, + Event: "PreToolUse", + Matcher: "Bash", + ActionType: "command", + Command: "./bin/project", + Path: "/tmp/project/.claude/settings.json", + Collectible: true, + }, + { + GroupID: "claude:user:/tmp/home/.claude/settings.json:PreToolUse:Bash", + SourceTool: "claude", + Scope: inspect.ScopeUser, + Event: "PreToolUse", + Matcher: "Bash", + ActionType: "command", + Command: "./bin/user", + Path: "/tmp/home/.claude/settings.json", + Collectible: true, + }, + } + + _, err := Collect(root, discovered, CollectOptions{Strategy: StrategyOverwrite}) + if err == nil { + t.Fatal("expected collect collision error") + } + if !strings.Contains(err.Error(), "canonical managed id") { + t.Fatalf("Collect() error = %v, want canonical id collision", err) + } + + store := NewStore(root) + all, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(all) != 0 { + t.Fatalf("List() len = %d, want 0 after collision failure", len(all)) + } +} + +func TestCollectHooks_CodexEmptyMatcherAndNumericTimeoutRoundTrip(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + if err := os.MkdirAll(filepath.Join(project, ".codex"), 0755); err != nil { + t.Fatalf("mkdir codex dir error = %v", err) + } + if err := os.WriteFile(filepath.Join(project, ".codex", "hooks.json"), []byte(`{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"./submit.sh","timeout":30}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"./stop.sh","timeoutSec":45}]}]}}`), 0644); err != nil { + t.Fatalf("write codex hook config error = %v", err) + } + + t.Setenv("HOME", home) + + discovered, warnings, err := inspect.ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(discovered) != 2 { + t.Fatalf("expected 2 discovered hook items, got %d", len(discovered)) + } + + var submitItem, stopItem inspect.HookItem + for _, item := range discovered { + if item.Event == "UserPromptSubmit" { + submitItem = item + } + if item.Event == "Stop" { + stopItem = item + } + if item.Matcher != "" { + t.Fatalf("expected empty matcher for codex event %s, got %q", item.Event, item.Matcher) + } + } + if submitItem.TimeoutSeconds == nil || *submitItem.TimeoutSeconds != 30 { + t.Fatalf("submit timeoutSeconds = %#v, want 30", submitItem.TimeoutSeconds) + } + if stopItem.TimeoutSeconds == nil || *stopItem.TimeoutSeconds != 45 { + t.Fatalf("stop timeoutSeconds = %#v, want 45", stopItem.TimeoutSeconds) + } + + result, err := Collect(project, discovered, CollectOptions{Strategy: StrategyOverwrite}) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + if len(result.Created) != 2 { + t.Fatalf("Collect() Created len = %d, want 2", len(result.Created)) + } + + store := NewStore(project) + submitID := mustCanonicalRelativePath(t, "codex", "UserPromptSubmit", "") + stopID := mustCanonicalRelativePath(t, "codex", "Stop", "") + + submitRecord, err := store.Get(submitID) + if err != nil { + t.Fatalf("Get(submit) error = %v", err) + } + if submitRecord.Matcher != "" { + t.Fatalf("submit matcher = %q, want empty", submitRecord.Matcher) + } + if len(submitRecord.Handlers) != 1 || submitRecord.Handlers[0].TimeoutSeconds == nil || *submitRecord.Handlers[0].TimeoutSeconds != 30 { + t.Fatalf("submit handlers = %#v, want numeric timeout preserved", submitRecord.Handlers) + } + + stopRecord, err := store.Get(stopID) + if err != nil { + t.Fatalf("Get(stop) error = %v", err) + } + if stopRecord.Matcher != "" { + t.Fatalf("stop matcher = %q, want empty", stopRecord.Matcher) + } + if len(stopRecord.Handlers) != 1 || stopRecord.Handlers[0].TimeoutSeconds == nil || *stopRecord.Handlers[0].TimeoutSeconds != 45 { + t.Fatalf("stop handlers = %#v, want numeric timeout preserved", stopRecord.Handlers) + } +} + +func TestCollectHooks_GeminiPreservesSequentialAndHandlerMetadata(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + project := filepath.Join(tmp, "project") + + if err := os.MkdirAll(filepath.Join(project, ".gemini"), 0o755); err != nil { + t.Fatalf("mkdir gemini dir error = %v", err) + } + if err := os.WriteFile( + filepath.Join(project, ".gemini", "settings.json"), + []byte(`{"hooks":{"BeforeTool":[{"matcher":"Read","sequential":true,"hooks":[{"type":"command","name":"lint-read","description":"Run read lint","command":"./bin/gemini-lint","timeout":30000}]}]}}`), + 0o644, + ); err != nil { + t.Fatalf("write gemini hook config error = %v", err) + } + + t.Setenv("HOME", home) + + discovered, warnings, err := inspect.ScanHooks(project) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(discovered) != 1 { + t.Fatalf("expected 1 discovered gemini hook item, got %d", len(discovered)) + } + if !discovered[0].Collectible { + t.Fatalf("discovered item collectible = false, want true") + } + + result, err := Collect(project, discovered, CollectOptions{Strategy: StrategyOverwrite}) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + if len(result.Created) != 1 { + t.Fatalf("Collect() Created = %v, want 1 created hook", result.Created) + } + + store := NewStore(project) + id := mustCanonicalRelativePath(t, "gemini", "BeforeTool", "Read") + got, err := store.Get(id) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if got.Sequential == nil || !*got.Sequential { + t.Fatalf("Get() Sequential = %#v, want true", got.Sequential) + } + if len(got.Handlers) != 1 { + t.Fatalf("Get() handlers len = %d, want 1", len(got.Handlers)) + } + if got.Handlers[0].Name != "lint-read" || got.Handlers[0].Description != "Run read lint" { + t.Fatalf("Get() gemini handler metadata = %#v, want name/description preserved", got.Handlers[0]) + } + if got.Handlers[0].Timeout != "30000" { + t.Fatalf("Get() gemini timeout = %q, want 30000", got.Handlers[0].Timeout) + } +} + +func TestCollectHooks_GeminiAllowsEmptyMatcher(t *testing.T) { + root := t.TempDir() + wantID := mustCanonicalRelativePath(t, "gemini", "Notification", "") + discovered := []inspect.HookItem{ + { + GroupID: "gemini:project:/tmp/project/.gemini/settings.json:Notification:", + SourceTool: "gemini", + Scope: inspect.ScopeProject, + Event: "Notification", + Matcher: "", + ActionType: "command", + Command: "./bin/notify", + Path: "/tmp/project/.gemini/settings.json", + Collectible: true, + }, + } + + result, err := Collect(root, discovered, CollectOptions{Strategy: StrategyOverwrite}) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + if len(result.Created) != 1 || result.Created[0] != wantID { + t.Fatalf("Collect() Created = %v, want %q", result.Created, wantID) + } + + store := NewStore(root) + got, err := store.Get(wantID) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if got.Matcher != "" { + t.Fatalf("Get() Matcher = %q, want empty matcher", got.Matcher) + } +} + +func TestCollectHooks_RollsBackOnLaterWriteFailure(t *testing.T) { + root := t.TempDir() + store := NewStore(root) + alphaID := mustCanonicalRelativePath(t, "claude", "PreToolUse", "Alpha") + betaID := mustCanonicalRelativePath(t, "claude", "PreToolUse", "Beta") + + _, err := store.Put(Save{ + ID: alphaID, + Tool: "claude", + Event: "PreToolUse", + Matcher: "Alpha", + Handlers: []Handler{{Type: "command", Command: "./bin/alpha"}}, + }) + if err != nil { + t.Fatalf("seed Put(alpha) error = %v", err) + } + _, err = store.Put(Save{ + ID: betaID, + Tool: "claude", + Event: "PreToolUse", + Matcher: "Beta", + Handlers: []Handler{{Type: "command", Command: "./bin/beta"}}, + }) + if err != nil { + t.Fatalf("seed Put(beta) error = %v", err) + } + + discovered := []inspect.HookItem{ + { + GroupID: "claude:project:/tmp/project/.claude/settings.json:PreToolUse:Alpha", + SourceTool: "claude", + Scope: inspect.ScopeProject, + Event: "PreToolUse", + Matcher: "Alpha", + ActionType: "command", + Command: "./bin/alpha-updated", + Path: "/tmp/project/.claude/settings.json", + Collectible: true, + }, + { + GroupID: "claude:project:/tmp/project/.claude/settings.json:PreToolUse:Beta", + SourceTool: "claude", + Scope: inspect.ScopeProject, + Event: "PreToolUse", + Matcher: "Beta", + ActionType: "command", + Command: "./bin/beta-updated", + Path: "/tmp/project/.claude/settings.json", + Collectible: true, + }, + } + + origWrite := hookWriteFile + defer func() { hookWriteFile = origWrite }() + + writeCalls := 0 + hookWriteFile = func(name string, data []byte, perm os.FileMode) error { + writeCalls++ + if writeCalls == 2 { + _ = origWrite(name, []byte("corrupt"), perm) + return errors.New("injected write failure") + } + return origWrite(name, data, perm) + } + + _, err = Collect(root, discovered, CollectOptions{Strategy: StrategyOverwrite}) + if err == nil { + t.Fatal("Collect() error = nil, want injected write failure") + } + + alpha, err := store.Get(alphaID) + if err != nil { + t.Fatalf("Get(alpha) error = %v", err) + } + if len(alpha.Handlers) != 1 || alpha.Handlers[0].Command != "./bin/alpha" { + t.Fatalf("alpha handlers = %#v, want original content restored", alpha.Handlers) + } + + beta, err := store.Get(betaID) + if err != nil { + t.Fatalf("Get(beta) error = %v", err) + } + if len(beta.Handlers) != 1 || beta.Handlers[0].Command != "./bin/beta" { + t.Fatalf("beta handlers = %#v, want original content restored", beta.Handlers) + } +} + +func TestCanonicalRelativePath_PunctuationOnlyMatcher(t *testing.T) { + id, err := canonicalRelativePath("claude", "PreToolUse", ".*") + if err != nil { + t.Fatalf("canonicalRelativePath() error = %v", err) + } + if id == "" { + t.Fatal("canonicalRelativePath() returned empty id") + } + if path.Base(id) == ".yaml" { + t.Fatalf("canonicalRelativePath() returned empty matcher stem: %q", id) + } +} + +func TestCanonicalRelativePath_DistinctMatchersDoNotCollide(t *testing.T) { + left, err := canonicalRelativePath("claude", "PreToolUse", "Bash!") + if err != nil { + t.Fatalf("canonicalRelativePath(left) error = %v", err) + } + right, err := canonicalRelativePath("claude", "PreToolUse", "Bash?") + if err != nil { + t.Fatalf("canonicalRelativePath(right) error = %v", err) + } + if left == right { + t.Fatalf("canonicalRelativePath() collision: %q", left) + } +} + +func mustCanonicalRelativePath(t *testing.T, tool, event, matcher string) string { + t.Helper() + id, err := canonicalRelativePath(tool, event, matcher) + if err != nil { + t.Fatalf("canonicalRelativePath(%q, %q, %q) error = %v", tool, event, matcher, err) + } + return id +} + +func hookItemWithCommand(t *testing.T, items []inspect.HookItem, command string) inspect.HookItem { + t.Helper() + for _, item := range items { + if item.Command == command { + return item + } + } + t.Fatalf("could not find discovered hook item with command %q", command) + return inspect.HookItem{} +} diff --git a/internal/resources/hooks/compile.go b/internal/resources/hooks/compile.go new file mode 100644 index 00000000..d2a27110 --- /dev/null +++ b/internal/resources/hooks/compile.go @@ -0,0 +1,190 @@ +package hooks + +import ( + "errors" + "fmt" + "path" + "path/filepath" + "sort" + "strings" + + "skillshare/internal/resources/adapters" +) + +type CompiledFile = adapters.CompiledFile + +var ErrUnsupportedTarget = errors.New("unsupported target") + +// CompileTarget compiles managed hook records into target-native files. +func CompileTarget(records []Record, targetFamily, targetName, projectRoot, rawConfig string) ([]CompiledFile, []string, error) { + targetFamily = strings.ToLower(strings.TrimSpace(targetFamily)) + targetName = strings.TrimSpace(targetName) + if targetFamily == "" { + return nil, nil, fmt.Errorf("target is required") + } + if targetName == "" { + targetName = targetFamily + } + + var ( + converted []adapters.HookRecord + warnings []string + ) + + for _, record := range records { + if record.Disabled || !matchesAssignedTarget(record.Targets, targetName) { + continue + } + adapterRecord, warn, err := normalizeRecord(record) + if err != nil { + return nil, nil, err + } + if warn != "" { + warnings = append(warnings, warn) + continue + } + if adapterRecord.Tool != targetFamily { + continue + } + converted = append(converted, adapterRecord) + } + + sort.Slice(converted, func(i, j int) bool { + return converted[i].RelativePath < converted[j].RelativePath + }) + + var ( + files []CompiledFile + adapterWarnings []string + err error + ) + + switch targetFamily { + case "claude": + files, adapterWarnings, err = adapters.CompileClaudeHooks(converted, projectRoot, rawConfig) + case "codex": + files, adapterWarnings, err = adapters.CompileCodexHooks(converted, projectRoot, rawConfig) + case "gemini": + files, adapterWarnings, err = adapters.CompileGeminiHooks(converted, projectRoot, rawConfig) + default: + return nil, nil, fmt.Errorf("%w %q", ErrUnsupportedTarget, targetFamily) + } + if len(converted) == 0 && strings.TrimSpace(rawConfig) == "" { + return nil, warnings, nil + } + if err != nil { + return nil, nil, err + } + + warnings = append(warnings, adapterWarnings...) + return files, warnings, nil +} + +func matchesAssignedTarget(targets []string, targetName string) bool { + normalized := normalizeAssignedTargets(targets) + if len(normalized) == 0 { + return true + } + for _, target := range normalized { + if target == targetName { + return true + } + } + return false +} + +func normalizeAssignedTargets(targets []string) []string { + if len(targets) == 0 { + return nil + } + out := make([]string, 0, len(targets)) + seen := make(map[string]struct{}, len(targets)) + for _, target := range targets { + target = strings.TrimSpace(target) + if target == "" { + continue + } + if _, ok := seen[target]; ok { + continue + } + seen[target] = struct{}{} + out = append(out, target) + } + if len(out) == 0 { + return nil + } + return out +} + +func normalizeRecord(record Record) (adapters.HookRecord, string, error) { + rel := strings.TrimSpace(record.RelativePath) + if rel == "" { + rel = strings.TrimSpace(record.ID) + } + rel = filepath.ToSlash(rel) + if rel != "" { + rel = path.Clean(rel) + } + if rel == "." { + rel = "" + } + if strings.HasPrefix(rel, "../") || strings.HasPrefix(rel, "/") { + return adapters.HookRecord{}, "", fmt.Errorf("invalid managed hook path %q", rel) + } + + tool := strings.ToLower(strings.TrimSpace(record.Tool)) + if tool == "" && rel != "" { + if parts := strings.SplitN(rel, "/", 2); len(parts) > 1 && strings.TrimSpace(parts[0]) != "" { + tool = strings.ToLower(strings.TrimSpace(parts[0])) + } + } + if tool == "" { + return adapters.HookRecord{}, fmt.Sprintf("skipping hook %q: missing tool", record.ID), nil + } + if rel == "" { + return adapters.HookRecord{}, fmt.Sprintf("skipping hook %q: missing relative path", record.ID), nil + } + if !strings.HasPrefix(rel, tool+"/") { + rel = path.Join(tool, strings.TrimPrefix(rel, "/")) + } + + event := strings.TrimSpace(record.Event) + if event == "" { + return adapters.HookRecord{}, fmt.Sprintf("skipping hook %q: missing event", record.ID), nil + } + matcher := strings.TrimSpace(record.Matcher) + if tool == "codex" && (event == "UserPromptSubmit" || event == "Stop") { + matcher = "" + } + if matcher == "" && !managedHookAllowsEmptyMatcher(tool, event) { + return adapters.HookRecord{}, fmt.Sprintf("skipping hook %q: missing matcher", record.ID), nil + } + if len(record.Handlers) == 0 { + return adapters.HookRecord{}, fmt.Sprintf("skipping hook %q: missing handlers", record.ID), nil + } + + handlers := make([]adapters.HookHandler, len(record.Handlers)) + for i, handler := range record.Handlers { + handlers[i] = adapters.HookHandler{ + Type: strings.TrimSpace(handler.Type), + Name: strings.TrimSpace(handler.Name), + Description: strings.TrimSpace(handler.Description), + Command: strings.TrimSpace(handler.Command), + URL: strings.TrimSpace(handler.URL), + Prompt: strings.TrimSpace(handler.Prompt), + Timeout: strings.TrimSpace(handler.Timeout), + TimeoutSeconds: handler.TimeoutSeconds, + StatusMessage: strings.TrimSpace(handler.StatusMessage), + } + } + + return adapters.HookRecord{ + ID: strings.TrimSpace(record.ID), + Tool: tool, + RelativePath: rel, + Event: event, + Matcher: matcher, + Sequential: copyOptionalBool(record.Sequential), + Handlers: handlers, + }, "", nil +} diff --git a/internal/resources/hooks/compile_test.go b/internal/resources/hooks/compile_test.go new file mode 100644 index 00000000..a61b8158 --- /dev/null +++ b/internal/resources/hooks/compile_test.go @@ -0,0 +1,130 @@ +package hooks + +import ( + "strings" + "testing" +) + +func TestCompileHooks_CodexAddsFeatureFlag(t *testing.T) { + configToml := "[profiles.default]\nmodel = \"gpt-5\"\n" + records := []Record{ + { + ID: "codex/pre-tool-use/bash.yaml", + RelativePath: "codex/pre-tool-use/bash.yaml", + Tool: "codex", + Event: "PreToolUse", + Matcher: "Bash", + Handlers: []Handler{{Type: "command", Command: "./bin/check"}}, + }, + } + + files, warnings, err := CompileTarget(records, "codex", "codex", "/tmp/project", configToml) + if err != nil { + t.Fatalf("CompileTarget() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if !containsCompiledContent(files, "/tmp/project/.codex/config.toml", "codex_hooks = true") { + t.Fatalf("expected codex_hooks feature flag") + } + if !containsCompiledPath(files, "/tmp/project/.codex/hooks.json") { + t.Fatalf("expected hooks.json output") + } +} + +func TestCompileHooks_RejectsInvalidRelativePath(t *testing.T) { + _, _, err := CompileTarget([]Record{ + { + ID: "../escape.yaml", + RelativePath: "../escape.yaml", + Tool: "codex", + Event: "PreToolUse", + Matcher: "Bash", + Handlers: []Handler{{Type: "command", Command: "./bin/check"}}, + }, + }, "codex", "codex", "/tmp/project", "") + if err == nil { + t.Fatal("expected invalid managed path error") + } +} + +func TestCompileHooks_SkipsDisabledHook(t *testing.T) { + files, warnings, err := CompileTarget([]Record{{ + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "PreToolUse", + Matcher: "Bash", + Disabled: true, + Handlers: []Handler{{Type: "command", Command: "./bin/check"}}, + }}, "claude", "claude-work", t.TempDir(), "") + if err != nil { + t.Fatalf("CompileTarget() error = %v", err) + } + if len(files) != 0 { + t.Fatalf("CompileTarget() files = %v, want none", files) + } + if len(warnings) != 0 { + t.Fatalf("CompileTarget() warnings = %v, want none", warnings) + } +} + +func TestCompileHooks_GeminiWritesSettingsJSON(t *testing.T) { + projectRoot := t.TempDir() + sequential := true + records := []Record{ + { + ID: "gemini/before-tool/read.yaml", + RelativePath: "gemini/before-tool/read.yaml", + Tool: "gemini", + Event: "BeforeTool", + Matcher: "Read", + Sequential: &sequential, + Handlers: []Handler{{ + Type: "command", + Name: "lint-read", + Description: "Run read lint", + Command: "./bin/gemini-lint", + Timeout: "30000", + }}, + }, + } + + files, warnings, err := CompileTarget(records, "gemini", "gemini", projectRoot, `{"theme":"light"}`) + if err != nil { + t.Fatalf("CompileTarget() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompileTarget() warnings = %v, want none", warnings) + } + if !containsCompiledPath(files, projectRoot+"/.gemini/settings.json") { + t.Fatalf("expected gemini settings output, got %v", files) + } + if !containsCompiledContent(files, projectRoot+"/.gemini/settings.json", `"name":"lint-read"`) { + t.Fatalf("expected gemini hook metadata in compiled settings") + } + if !containsCompiledContent(files, projectRoot+"/.gemini/settings.json", `"sequential":true`) { + t.Fatalf("expected gemini sequential flag in compiled settings") + } + if !containsCompiledContent(files, projectRoot+"/.gemini/settings.json", `"theme":"light"`) { + t.Fatalf("expected raw gemini config content to be preserved") + } +} + +func containsCompiledContent(files []CompiledFile, wantPath, wantSubstring string) bool { + for _, file := range files { + if file.Path == wantPath { + return strings.Contains(file.Content, wantSubstring) + } + } + return false +} + +func containsCompiledPath(files []CompiledFile, wantPath string) bool { + for _, file := range files { + if file.Path == wantPath { + return true + } + } + return false +} diff --git a/internal/resources/hooks/identity.go b/internal/resources/hooks/identity.go new file mode 100644 index 00000000..32673bfb --- /dev/null +++ b/internal/resources/hooks/identity.go @@ -0,0 +1,6 @@ +package hooks + +// CanonicalRelativePath returns the managed hook ID for a tool/event/matcher triplet. +func CanonicalRelativePath(tool, event, matcher string) (string, error) { + return canonicalRelativePath(tool, event, matcher) +} diff --git a/internal/resources/hooks/replace_nonwindows.go b/internal/resources/hooks/replace_nonwindows.go new file mode 100644 index 00000000..287cab25 --- /dev/null +++ b/internal/resources/hooks/replace_nonwindows.go @@ -0,0 +1,9 @@ +//go:build !windows + +package hooks + +import "os" + +func (s *Store) replaceHookFile(tempPath, fullPath string) error { + return os.Rename(tempPath, fullPath) +} diff --git a/internal/resources/hooks/replace_windows.go b/internal/resources/hooks/replace_windows.go new file mode 100644 index 00000000..6339c23c --- /dev/null +++ b/internal/resources/hooks/replace_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package hooks + +import "golang.org/x/sys/windows" + +func (s *Store) replaceHookFile(tempPath, fullPath string) error { + return windows.Rename(tempPath, fullPath) +} diff --git a/internal/resources/hooks/store.go b/internal/resources/hooks/store.go new file mode 100644 index 00000000..10a44d2d --- /dev/null +++ b/internal/resources/hooks/store.go @@ -0,0 +1,493 @@ +package hooks + +import ( + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strconv" + "strings" + + "gopkg.in/yaml.v3" + + "skillshare/internal/config" +) + +var hookWriteFile = os.WriteFile + +// Store persists managed matcher-group hooks as YAML files under the managed hooks root. +type Store struct { + root string +} + +// NewStore creates a hook store for global mode (empty projectRoot) or project mode. +func NewStore(projectRoot string) *Store { + return &Store{ + root: config.ManagedHooksDir(projectRoot), + } +} + +type hookFile struct { + Tool string `yaml:"tool"` + Event string `yaml:"event"` + Matcher string `yaml:"matcher"` + Sequential *bool `yaml:"sequential,omitempty"` + Handlers []Handler `yaml:"handlers"` + Targets []string `yaml:"targets,omitempty"` + SourceType string `yaml:"sourceType,omitempty"` + Disabled bool `yaml:"disabled,omitempty"` +} + +// Put writes one matcher-group hook file for the provided ID. +func (s *Store) Put(in Save) (Record, error) { + fullPath, id, err := s.pathForID(in.ID) + if err != nil { + return Record{}, err + } + if err := validateSave(in, id); err != nil { + return Record{}, err + } + + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + return Record{}, fmt.Errorf("create hook directory: %w", err) + } + + data, err := yaml.Marshal(hookFile{ + Tool: strings.TrimSpace(in.Tool), + Event: strings.TrimSpace(in.Event), + Matcher: strings.TrimSpace(in.Matcher), + Sequential: copyOptionalBool(in.Sequential), + Handlers: sanitizeHandlers(in.Handlers), + Targets: sanitizeTargets(in.Targets), + SourceType: strings.TrimSpace(in.SourceType), + Disabled: in.Disabled, + }) + if err != nil { + return Record{}, fmt.Errorf("marshal hook: %w", err) + } + + tempPath, err := s.writeTempHook(filepath.Dir(fullPath), data) + if err != nil { + return Record{}, fmt.Errorf("write hook: %w", err) + } + if err := s.replaceHookFile(tempPath, fullPath); err != nil { + _ = os.Remove(tempPath) + return Record{}, fmt.Errorf("write hook: rename temp file: %w", err) + } + + return Record{ + ID: id, + Path: fullPath, + RelativePath: id, + Tool: strings.TrimSpace(in.Tool), + Event: strings.TrimSpace(in.Event), + Matcher: strings.TrimSpace(in.Matcher), + Sequential: copyOptionalBool(in.Sequential), + Handlers: sanitizeHandlers(in.Handlers), + Targets: sanitizeTargets(in.Targets), + SourceType: strings.TrimSpace(in.SourceType), + Disabled: in.Disabled, + }, nil +} + +// Get loads one managed matcher-group hook by ID. +func (s *Store) Get(id string) (Record, error) { + fullPath, cleanedID, err := s.pathForID(id) + if err != nil { + return Record{}, err + } + + data, err := os.ReadFile(fullPath) + if err != nil { + return Record{}, err + } + + var file hookFile + if err := yaml.Unmarshal(data, &file); err != nil { + return Record{}, fmt.Errorf("parse hook %q: %w", cleanedID, err) + } + file = normalizeLoadedHookFile(file) + if err := validateFile(cleanedID, file); err != nil { + return Record{}, err + } + + return Record{ + ID: cleanedID, + Path: fullPath, + RelativePath: cleanedID, + Tool: strings.TrimSpace(file.Tool), + Event: strings.TrimSpace(file.Event), + Matcher: strings.TrimSpace(file.Matcher), + Sequential: copyOptionalBool(file.Sequential), + Handlers: sanitizeHandlers(file.Handlers), + Targets: sanitizeTargets(file.Targets), + SourceType: strings.TrimSpace(file.SourceType), + Disabled: file.Disabled, + }, nil +} + +// List returns all managed matcher-group hooks under the store root. +func (s *Store) List() ([]Record, error) { + if _, err := os.Stat(s.root); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var out []Record + err := filepath.WalkDir(s.root, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + if strings.HasPrefix(filepath.Base(p), ".hook-tmp-") { + return nil + } + + rel, err := filepath.Rel(s.root, p) + if err != nil { + return err + } + id := filepath.ToSlash(rel) + + data, err := os.ReadFile(p) + if err != nil { + return err + } + + var file hookFile + if err := yaml.Unmarshal(data, &file); err != nil { + return fmt.Errorf("parse hook %q: %w", id, err) + } + file = normalizeLoadedHookFile(file) + if err := validateFile(id, file); err != nil { + return err + } + + out = append(out, Record{ + ID: id, + Path: p, + RelativePath: id, + Tool: strings.TrimSpace(file.Tool), + Event: strings.TrimSpace(file.Event), + Matcher: strings.TrimSpace(file.Matcher), + Sequential: copyOptionalBool(file.Sequential), + Handlers: sanitizeHandlers(file.Handlers), + Targets: sanitizeTargets(file.Targets), + SourceType: strings.TrimSpace(file.SourceType), + Disabled: file.Disabled, + }) + return nil + }) + if err != nil { + return nil, err + } + + sort.Slice(out, func(i, j int) bool { + return out[i].ID < out[j].ID + }) + return out, nil +} + +// Delete removes one managed matcher-group hook by ID. +func (s *Store) Delete(id string) error { + fullPath, _, err := s.pathForID(id) + if err != nil { + return err + } + return os.Remove(fullPath) +} + +func (s *Store) pathForID(id string) (fullPath string, cleanedID string, err error) { + normalized := strings.ReplaceAll(strings.TrimSpace(id), "\\", "/") + cleanedID = path.Clean(normalized) + + if cleanedID == "" || cleanedID == "." || cleanedID == ".." { + return "", "", fmt.Errorf("invalid hook id %q", id) + } + if strings.HasPrefix(cleanedID, "/") || strings.HasPrefix(cleanedID, "../") { + return "", "", fmt.Errorf("invalid hook id %q", id) + } + if len(cleanedID) >= 2 && cleanedID[1] == ':' { + return "", "", fmt.Errorf("invalid hook id %q", id) + } + for _, part := range strings.Split(cleanedID, "/") { + if strings.HasPrefix(part, ".hook-tmp-") { + return "", "", fmt.Errorf("invalid hook id %q", id) + } + } + + fullPath = filepath.Join(s.root, filepath.FromSlash(cleanedID)) + return fullPath, cleanedID, nil +} + +func validateSave(in Save, id string) error { + file := hookFile{ + Tool: in.Tool, + Event: in.Event, + Matcher: in.Matcher, + Sequential: in.Sequential, + Handlers: in.Handlers, + } + return validateFile(id, file) +} + +func validateFile(id string, in hookFile) error { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("hook id is required") + } + if strings.TrimSpace(in.Tool) == "" { + return fmt.Errorf("hook %q: tool is required", id) + } + tool := strings.ToLower(strings.TrimSpace(in.Tool)) + if !isSupportedManagedHookTool(tool) { + return fmt.Errorf("hook %q: tool %q is not supported", id, in.Tool) + } + idTool, ok := managedHookToolFromID(id) + if !ok { + return fmt.Errorf("hook %q: managed hook id must start with a supported tool prefix", id) + } + if tool != idTool { + return fmt.Errorf("hook %q: tool %q does not match managed id prefix %q", id, in.Tool, idTool) + } + if tool == "codex" { + if err := validateCodexManagedHook(id, in); err != nil { + return err + } + } + if tool == "gemini" { + if err := validateGeminiManagedHook(id, in); err != nil { + return err + } + } + if strings.TrimSpace(in.Event) == "" { + return fmt.Errorf("hook %q: event is required", id) + } + if strings.TrimSpace(in.Matcher) == "" && !managedHookAllowsEmptyMatcher(tool, strings.TrimSpace(in.Event)) { + return fmt.Errorf("hook %q: matcher is required", id) + } + if len(in.Handlers) == 0 { + return fmt.Errorf("hook %q: handlers must not be empty", id) + } + for i, h := range in.Handlers { + actionType := strings.TrimSpace(h.Type) + if actionType == "" { + return fmt.Errorf("hook %q: handlers[%d].type is required", id, i) + } + switch actionType { + case "command": + if strings.TrimSpace(h.Command) == "" { + return fmt.Errorf("hook %q: handlers[%d].command is required for type command", id, i) + } + case "http": + if strings.TrimSpace(h.URL) == "" { + return fmt.Errorf("hook %q: handlers[%d].url is required for type http", id, i) + } + case "prompt", "agent": + if strings.TrimSpace(h.Prompt) == "" { + return fmt.Errorf("hook %q: handlers[%d].prompt is required for type %s", id, i, actionType) + } + default: + return fmt.Errorf("hook %q: handlers[%d].type %q is not supported", id, i, actionType) + } + } + return nil +} + +func validateCodexManagedHook(id string, in hookFile) error { + if !isSupportedCodexManagedEvent(strings.TrimSpace(in.Event)) { + return fmt.Errorf("hook %q: event %q is not supported for codex", id, in.Event) + } + if event := strings.TrimSpace(in.Event); event == "UserPromptSubmit" || event == "Stop" { + if strings.TrimSpace(in.Matcher) != "" { + return fmt.Errorf("hook %q: matcher must be empty for codex %s", id, event) + } + } + for i, h := range in.Handlers { + actionType := strings.TrimSpace(h.Type) + if actionType != "command" { + return fmt.Errorf("hook %q: handlers[%d].type %q is not supported for codex", id, i, actionType) + } + if strings.TrimSpace(h.Command) == "" { + return fmt.Errorf("hook %q: handlers[%d].command is required for codex", id, i) + } + if strings.TrimSpace(h.Timeout) != "" && h.TimeoutSeconds == nil { + if _, err := strconv.Atoi(strings.TrimSpace(h.Timeout)); err != nil { + return fmt.Errorf("hook %q: handlers[%d].timeout must be numeric seconds for codex", id, i) + } + } + } + return nil +} + +func isSupportedCodexManagedEvent(event string) bool { + switch strings.TrimSpace(event) { + case "SessionStart", "PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop": + return true + default: + return false + } +} + +func isSupportedManagedHookTool(tool string) bool { + switch strings.ToLower(strings.TrimSpace(tool)) { + case "claude", "codex", "gemini": + return true + default: + return false + } +} + +func managedHookToolFromID(id string) (string, bool) { + normalized := strings.ReplaceAll(strings.TrimSpace(id), "\\", "/") + cleaned := path.Clean(normalized) + if cleaned == "" || cleaned == "." || cleaned == ".." { + return "", false + } + parts := strings.SplitN(cleaned, "/", 2) + if len(parts) < 2 || strings.TrimSpace(parts[0]) == "" { + return "", false + } + return strings.ToLower(strings.TrimSpace(parts[0])), true +} + +func sanitizeHandlers(in []Handler) []Handler { + if len(in) == 0 { + return nil + } + out := make([]Handler, len(in)) + for i, h := range in { + out[i] = Handler{ + Type: strings.TrimSpace(h.Type), + Name: strings.TrimSpace(h.Name), + Description: strings.TrimSpace(h.Description), + Command: strings.TrimSpace(h.Command), + URL: strings.TrimSpace(h.URL), + Prompt: strings.TrimSpace(h.Prompt), + Timeout: strings.TrimSpace(h.Timeout), + TimeoutSeconds: h.TimeoutSeconds, + StatusMessage: strings.TrimSpace(h.StatusMessage), + } + } + return out +} + +func normalizeLoadedHookFile(in hookFile) hookFile { + if strings.ToLower(strings.TrimSpace(in.Tool)) != "gemini" { + return in + } + in.Handlers = normalizeLegacyGeminiTimeouts(in.Handlers) + return in +} + +func normalizeLegacyGeminiTimeouts(in []Handler) []Handler { + if len(in) == 0 { + return nil + } + out := sanitizeHandlers(in) + for i := range out { + timeout := strings.TrimSpace(out[i].Timeout) + if timeout == "" || out[i].TimeoutSeconds == nil { + continue + } + if _, err := strconv.Atoi(timeout); err == nil { + continue + } + out[i].Timeout = strconv.Itoa(*out[i].TimeoutSeconds) + } + return out +} + +func validateGeminiManagedHook(id string, in hookFile) error { + if !isSupportedGeminiManagedEvent(strings.TrimSpace(in.Event)) { + return fmt.Errorf("hook %q: event %q is not supported for gemini", id, in.Event) + } + for i, h := range in.Handlers { + actionType := strings.TrimSpace(h.Type) + if actionType != "command" { + return fmt.Errorf("hook %q: handlers[%d].type %q is not supported for gemini", id, i, actionType) + } + if strings.TrimSpace(h.Command) == "" { + return fmt.Errorf("hook %q: handlers[%d].command is required for gemini", id, i) + } + if strings.TrimSpace(h.Timeout) != "" { + if _, err := strconv.Atoi(strings.TrimSpace(h.Timeout)); err != nil { + return fmt.Errorf("hook %q: handlers[%d].timeout must be numeric milliseconds for gemini", id, i) + } + } + } + return nil +} + +func isSupportedGeminiManagedEvent(event string) bool { + switch strings.TrimSpace(event) { + case "SessionStart", "SessionEnd", "BeforeAgent", "AfterAgent", "BeforeModel", "AfterModel", "BeforeToolSelection", "BeforeTool", "AfterTool", "PreCompress", "Notification": + return true + default: + return false + } +} + +func managedHookAllowsEmptyMatcher(tool, event string) bool { + tool = strings.ToLower(strings.TrimSpace(tool)) + event = strings.TrimSpace(event) + if tool == "codex" && (event == "UserPromptSubmit" || event == "Stop") { + return true + } + return tool == "gemini" +} + +func copyOptionalBool(value *bool) *bool { + if value == nil { + return nil + } + copy := *value + return © +} + +func sanitizeTargets(in []string) []string { + if len(in) == 0 { + return nil + } + out := make([]string, 0, len(in)) + for _, target := range in { + target = strings.TrimSpace(target) + if target == "" { + continue + } + out = append(out, target) + } + if len(out) == 0 { + return nil + } + return out +} + +func (s *Store) writeTempHook(dir string, data []byte) (string, error) { + tempFile, err := os.CreateTemp(dir, ".hook-tmp-*") + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + + tempPath := tempFile.Name() + closeWithCleanup := func(writeErr error) (string, error) { + _ = tempFile.Close() + _ = os.Remove(tempPath) + return "", writeErr + } + + if err := tempFile.Close(); err != nil { + return closeWithCleanup(fmt.Errorf("close temp file: %w", err)) + } + + if err := hookWriteFile(tempPath, data, 0644); err != nil { + return closeWithCleanup(err) + } + + return tempPath, nil +} diff --git a/internal/resources/hooks/store_test.go b/internal/resources/hooks/store_test.go new file mode 100644 index 00000000..9942170b --- /dev/null +++ b/internal/resources/hooks/store_test.go @@ -0,0 +1,493 @@ +package hooks + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestHookStore_PutGetListDelete(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + wantHandlers := []Handler{ + { + Type: "command", + Command: "./scripts/guard.sh", + Timeout: "30s", + StatusMessage: "Running guard checks", + }, + { + Type: "http", + URL: "https://example.com/hook", + Timeout: "5s", + StatusMessage: "Sending webhook", + }, + { + Type: "prompt", + Prompt: "Summarize the tool input", + StatusMessage: "Prompting", + }, + } + + saved, err := store.Put(Save{ + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "pre-tool-use", + Matcher: `^bash\b`, + Handlers: wantHandlers, + }) + if err != nil { + t.Fatalf("Put() error = %v", err) + } + if saved.ID != "claude/pre-tool-use/bash.yaml" { + t.Fatalf("Put() ID = %q, want %q", saved.ID, "claude/pre-tool-use/bash.yaml") + } + + got, err := store.Get("claude/pre-tool-use/bash.yaml") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if got.Tool != "claude" { + t.Fatalf("Get() Tool = %q, want %q", got.Tool, "claude") + } + if got.Event != "pre-tool-use" { + t.Fatalf("Get() Event = %q, want %q", got.Event, "pre-tool-use") + } + if got.Matcher != `^bash\b` { + t.Fatalf("Get() Matcher = %q, want %q", got.Matcher, `^bash\b`) + } + if len(got.Handlers) != len(wantHandlers) { + t.Fatalf("Get() Handlers len = %d, want %d", len(got.Handlers), len(wantHandlers)) + } + for i := range wantHandlers { + if got.Handlers[i] != wantHandlers[i] { + t.Fatalf("Get() Handlers[%d] = %#v, want %#v", i, got.Handlers[i], wantHandlers[i]) + } + } + + all, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(all) != 1 { + t.Fatalf("List() len = %d, want 1", len(all)) + } + if all[0].ID != "claude/pre-tool-use/bash.yaml" { + t.Fatalf("List()[0].ID = %q, want %q", all[0].ID, "claude/pre-tool-use/bash.yaml") + } + + if err := store.Delete("claude/pre-tool-use/bash.yaml"); err != nil { + t.Fatalf("Delete() error = %v", err) + } + + _, err = store.Get("claude/pre-tool-use/bash.yaml") + if !os.IsNotExist(err) { + t.Fatalf("Get() after Delete error = %v, want not-exist", err) + } +} + +func TestHookStore_PutAndGet_RoundTripsMetadata(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + saved, err := store.Put(Save{ + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "pre-tool-use", + Matcher: "^bash\\b", + Targets: []string{"claude-work"}, + SourceType: "local", + Disabled: true, + Handlers: []Handler{ + {Type: "command", Command: "./bin/check"}, + }, + }) + if err != nil { + t.Fatalf("Put() error = %v", err) + } + + got, err := store.Get(saved.ID) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if !reflect.DeepEqual(got.Targets, []string{"claude-work"}) { + t.Fatalf("Get() Targets = %#v, want %#v", got.Targets, []string{"claude-work"}) + } + if got.SourceType != "local" { + t.Fatalf("Get() SourceType = %q, want %q", got.SourceType, "local") + } + if !got.Disabled { + t.Fatal("Get() Disabled = false, want true") + } +} + +func TestHookStore_GeminiRoundTripsSequentialAndHandlerMetadata(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + sequential := true + + saved, err := store.Put(Save{ + ID: "gemini/before-tool/read.yaml", + Tool: "gemini", + Event: "BeforeTool", + Matcher: "Read", + Sequential: &sequential, + Handlers: []Handler{{ + Type: "command", + Name: "lint-read", + Description: "Run read lint", + Command: "./bin/gemini-lint", + Timeout: "30000", + }}, + }) + if err != nil { + t.Fatalf("Put() error = %v", err) + } + + got, err := store.Get(saved.ID) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if got.Sequential == nil || !*got.Sequential { + t.Fatalf("Get() Sequential = %#v, want true", got.Sequential) + } + if len(got.Handlers) != 1 { + t.Fatalf("Get() Handlers len = %d, want 1", len(got.Handlers)) + } + if got.Handlers[0].Name != "lint-read" || got.Handlers[0].Description != "Run read lint" { + t.Fatalf("Get() gemini handler metadata = %#v, want name/description preserved", got.Handlers[0]) + } +} + +func TestHookStore_LoadsLegacyGeminiTimeoutUsingTimeoutSeconds(t *testing.T) { + store := NewStore(t.TempDir()) + fullPath, id, err := store.pathForID("gemini/before-tool/read.yaml") + if err != nil { + t.Fatalf("pathForID() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatalf("mkdir hook dir: %v", err) + } + if err := os.WriteFile(fullPath, []byte("tool: gemini\nevent: BeforeTool\nmatcher: Read\nhandlers:\n - type: command\n command: ./bin/check\n timeout: 30s\n timeoutSec: 30000\n"), 0o644); err != nil { + t.Fatalf("write legacy gemini hook: %v", err) + } + + got, err := store.Get(id) + if err != nil { + t.Fatalf("Get() error = %v, want legacy gemini timeout compatibility", err) + } + if len(got.Handlers) != 1 { + t.Fatalf("Get() Handlers len = %d, want 1", len(got.Handlers)) + } + if got.Handlers[0].Timeout != "30000" { + t.Fatalf("Get() Timeout = %q, want canonicalized timeout from timeoutSec", got.Handlers[0].Timeout) + } + if got.Handlers[0].TimeoutSeconds == nil || *got.Handlers[0].TimeoutSeconds != 30000 { + t.Fatalf("Get() TimeoutSeconds = %#v, want 30000", got.Handlers[0].TimeoutSeconds) + } + + all, err := store.List() + if err != nil { + t.Fatalf("List() error = %v, want legacy gemini timeout compatibility", err) + } + if len(all) != 1 { + t.Fatalf("List() len = %d, want 1", len(all)) + } + if all[0].Handlers[0].Timeout != "30000" { + t.Fatalf("List() Timeout = %q, want canonicalized timeout from timeoutSec", all[0].Handlers[0].Timeout) + } +} + +func TestHookStore_RejectsEmptyHandlers(t *testing.T) { + store := NewStore(t.TempDir()) + + cases := []Save{ + { + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "pre-tool-use", + Matcher: "^bash", + Handlers: nil, + }, + { + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "pre-tool-use", + Matcher: "^bash", + Handlers: []Handler{}, + }, + { + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "pre-tool-use", + Matcher: "^bash", + Handlers: []Handler{ + {Command: "./scripts/guard.sh"}, + }, + }, + { + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "pre-tool-use", + Matcher: "^bash", + Handlers: []Handler{ + {Type: "command"}, + }, + }, + { + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "pre-tool-use", + Matcher: "^bash", + Handlers: []Handler{ + {Type: "http"}, + }, + }, + { + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "pre-tool-use", + Matcher: "^bash", + Handlers: []Handler{ + {Type: "prompt"}, + }, + }, + { + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "pre-tool-use", + Matcher: "^bash", + Handlers: []Handler{ + {Type: "agent"}, + }, + }, + { + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "pre-tool-use", + Matcher: "^bash", + Handlers: []Handler{ + {Type: "unknown", Command: "./scripts/guard.sh"}, + }, + }, + } + + for _, tc := range cases { + if _, err := store.Put(tc); err == nil { + t.Fatalf("Put() error = nil, want error for handlers=%#v", tc.Handlers) + } + } +} + +func TestHookStore_RejectsInvalidIDs(t *testing.T) { + store := NewStore(t.TempDir()) + + invalidIDs := []string{ + "", + " ", + ".", + "..", + "../outside.yaml", + "..\\outside.yaml", + "..\\..\\outside.yaml", + "/tmp/outside.yaml", + `C:\outside.yaml`, + "C:/outside.yaml", + `\\server\share\file.yaml`, + "claude/.hook-tmp-test.yaml", + "claude/pre-tool-use/.hook-tmp-test.yaml", + } + + validSave := Save{ + Tool: "claude", + Event: "pre-tool-use", + Matcher: "^bash", + Handlers: []Handler{ + {Type: "command", Command: "./scripts/guard.sh"}, + }, + } + + for _, id := range invalidIDs { + id := id + t.Run(id, func(t *testing.T) { + in := validSave + in.ID = id + if _, err := store.Put(in); err == nil { + t.Fatalf("Put(%q) error = nil, want error", id) + } + if _, err := store.Get(id); err == nil { + t.Fatalf("Get(%q) error = nil, want error", id) + } + if err := store.Delete(id); err == nil { + t.Fatalf("Delete(%q) error = nil, want error", id) + } + }) + } +} + +func TestHookStore_ListIgnoresTempFiles(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + _, err := store.Put(Save{ + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "pre-tool-use", + Matcher: "^bash\\b", + Handlers: []Handler{{Type: "command", Command: "./scripts/guard.sh"}}, + }) + if err != nil { + t.Fatalf("Put() error = %v", err) + } + + managedRoot := filepath.Join(projectRoot, ".skillshare", "hooks", "claude", "pre-tool-use") + if err := os.MkdirAll(managedRoot, 0755); err != nil { + t.Fatalf("mkdir managed root error = %v", err) + } + if err := os.WriteFile(filepath.Join(managedRoot, ".hook-tmp-crash"), []byte("not yaml"), 0644); err != nil { + t.Fatalf("write stray temp file error = %v", err) + } + + all, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(all) != 1 { + t.Fatalf("List() len = %d, want 1", len(all)) + } + if all[0].ID != "claude/pre-tool-use/bash.yaml" { + t.Fatalf("List()[0].ID = %q, want %q", all[0].ID, "claude/pre-tool-use/bash.yaml") + } +} + +func TestHookStore_RejectsMismatchedToolAndIDPrefix(t *testing.T) { + store := NewStore(t.TempDir()) + + cases := []Save{ + { + ID: "claude/pre-tool-use/bash.yaml", + Tool: "codex", + Event: "pre-tool-use", + Matcher: "^bash", + Handlers: []Handler{{Type: "command", Command: "./scripts/guard.sh"}}, + }, + } + + for _, tc := range cases { + if _, err := store.Put(tc); err == nil { + t.Fatalf("Put(%#v) error = nil, want validation error", tc) + } + } +} + +func TestHookStore_RejectsInvalidGeminiRecords(t *testing.T) { + store := NewStore(t.TempDir()) + timeoutMillis := 30000 + + cases := []Save{ + { + ID: "gemini/before-tool/read.yaml", + Tool: "gemini", + Event: "FileChanged", + Matcher: "Read", + Handlers: []Handler{{Type: "command", Command: "./bin/check"}}, + }, + { + ID: "gemini/before-tool/read.yaml", + Tool: "gemini", + Event: "BeforeTool", + Matcher: "Read", + Handlers: []Handler{{Type: "http", URL: "https://example.com/hook"}}, + }, + { + ID: "gemini/before-tool/read.yaml", + Tool: "gemini", + Event: "BeforeTool", + Matcher: "Read", + Handlers: []Handler{{Type: "command", Command: "./bin/check", Timeout: "30s"}}, + }, + { + ID: "gemini/before-tool/read.yaml", + Tool: "gemini", + Event: "BeforeTool", + Matcher: "Read", + Handlers: []Handler{{Type: "command", Command: "./bin/check", Timeout: "30s", TimeoutSeconds: &timeoutMillis}}, + }, + } + + for _, tc := range cases { + if _, err := store.Put(tc); err == nil { + t.Fatalf("Put(%#v) error = nil, want validation error", tc) + } + } +} + +func TestHookStore_RejectsCodexMatchersForMatcherlessEvents(t *testing.T) { + store := NewStore(t.TempDir()) + + cases := []Save{ + { + ID: "codex/user-prompt-submit/bash.yaml", + Tool: "codex", + Event: "UserPromptSubmit", + Matcher: "Bash", + Handlers: []Handler{{Type: "command", Command: "./scripts/guard.sh"}}, + }, + { + ID: "codex/stop/bash.yaml", + Tool: "codex", + Event: "Stop", + Matcher: "Write", + Handlers: []Handler{{Type: "command", Command: "./scripts/guard.sh"}}, + }, + } + + for _, tc := range cases { + if _, err := store.Put(tc); err == nil { + t.Fatalf("Put(%#v) error = nil, want matcher validation error", tc) + } + } +} + +func TestHookStore_RejectsInvalidCodexRecords(t *testing.T) { + store := NewStore(t.TempDir()) + + cases := []Save{ + { + ID: "codex/pre-tool-use/bash.yaml", + Tool: "codex", + Event: "FileChanged", + Matcher: "^bash", + Handlers: []Handler{{Type: "command", Command: "./bin/check"}}, + }, + { + ID: "codex/pre-tool-use/bash.yaml", + Tool: "codex", + Event: "PreToolUse", + Matcher: "^bash", + Handlers: []Handler{{Type: "http", URL: "https://example.com/hook"}}, + }, + { + ID: "codex/pre-tool-use/bash.yaml", + Tool: "codex", + Event: "PreToolUse", + Matcher: "^bash", + Handlers: []Handler{{Type: "prompt", Prompt: "Summarize"}}, + }, + { + ID: "codex/pre-tool-use/bash.yaml", + Tool: "codex", + Event: "PreToolUse", + Matcher: "^bash", + Handlers: []Handler{{Type: "agent", Prompt: "Summarize"}}, + }, + } + + for _, tc := range cases { + if _, err := store.Put(tc); err == nil { + t.Fatalf("Put(%#v) error = nil, want validation error", tc) + } + } +} diff --git a/internal/resources/hooks/types.go b/internal/resources/hooks/types.go new file mode 100644 index 00000000..bdd910f7 --- /dev/null +++ b/internal/resources/hooks/types.go @@ -0,0 +1,42 @@ +package hooks + +// Record is one managed matcher-group hook loaded from disk. +type Record struct { + ID string + Path string + RelativePath string + Tool string + Event string + Matcher string + Sequential *bool + Handlers []Handler + Targets []string + SourceType string + Disabled bool +} + +// Save is the payload for persisting one managed matcher-group hook. +type Save struct { + ID string + Tool string + Event string + Matcher string + Sequential *bool + Handlers []Handler + Targets []string + SourceType string + Disabled bool +} + +// Handler is one action within a managed matcher-group hook. +type Handler struct { + Type string `yaml:"type"` + Name string `yaml:"name,omitempty"` + Description string `yaml:"description,omitempty"` + Command string `yaml:"command,omitempty"` + URL string `yaml:"url,omitempty"` + Prompt string `yaml:"prompt,omitempty"` + Timeout string `yaml:"timeout,omitempty"` + TimeoutSeconds *int `yaml:"timeoutSec,omitempty"` + StatusMessage string `yaml:"statusMessage,omitempty"` +} diff --git a/internal/resources/managed/capabilities.go b/internal/resources/managed/capabilities.go new file mode 100644 index 00000000..eea2316a --- /dev/null +++ b/internal/resources/managed/capabilities.go @@ -0,0 +1,322 @@ +package managed + +import ( + "path/filepath" + "sort" + "strings" + + "skillshare/internal/config" + managedpi "skillshare/internal/resources/managed/pi" +) + +type ResourceKind string + +const ( + ResourceKindRules ResourceKind = "rules" + ResourceKindHooks ResourceKind = "hooks" +) + +type FamilySpec struct { + Name string + SupportsRules bool + SupportsHooks bool + CompatibleTargets []string + RuleInstructionFiles []string + HookConfigFiles []string +} + +type TargetClassification struct { + Name string `json:"name"` + RulesFamily string `json:"rulesFamily,omitempty"` + HooksFamily string `json:"hooksFamily,omitempty"` + Status []string `json:"status"` +} + +type CapabilitySnapshotPayload struct { + Families map[string]FamilySpec + Targets map[string]TargetClassification +} + +type capabilityRegistry struct { + families map[string]FamilySpec + targets map[string]TargetClassification +} + +var managedCapabilities = newCapabilityRegistry() + +func newCapabilityRegistry() capabilityRegistry { + families := map[string]FamilySpec{ + "claude": { + Name: "claude", + SupportsRules: true, + SupportsHooks: true, + CompatibleTargets: []string{"claude", "claude-code"}, + RuleInstructionFiles: []string{"CLAUDE.md", ".claude/rules/**"}, + HookConfigFiles: []string{".claude/settings.json"}, + }, + "codex": { + Name: "codex", + SupportsRules: true, + SupportsHooks: true, + CompatibleTargets: []string{"codex", "universal", "agents"}, + RuleInstructionFiles: []string{"AGENTS.md", ".codex/AGENTS.md"}, + HookConfigFiles: []string{".codex/config.toml", ".codex/hooks.json"}, + }, + "gemini": { + Name: "gemini", + SupportsRules: true, + SupportsHooks: true, + CompatibleTargets: []string{"gemini", "gemini-cli"}, + RuleInstructionFiles: []string{"GEMINI.md", ".gemini/rules/**"}, + HookConfigFiles: []string{".gemini/settings.json"}, + }, + "pi": { + Name: "pi", + SupportsRules: true, + SupportsHooks: false, + CompatibleTargets: []string{"pi"}, + RuleInstructionFiles: managedpi.RuleInstructionFiles(), + }, + } + + targets := map[string]TargetClassification{ + "claude": { + Name: "claude", + RulesFamily: "claude", + HooksFamily: "claude", + Status: []string{"rules", "hooks"}, + }, + "codex": { + Name: "codex", + RulesFamily: "codex", + HooksFamily: "codex", + Status: []string{"rules", "hooks"}, + }, + "universal": { + Name: "universal", + RulesFamily: "codex", + HooksFamily: "codex", + Status: []string{"rules", "hooks"}, + }, + "gemini": { + Name: "gemini", + RulesFamily: "gemini", + HooksFamily: "gemini", + Status: []string{"rules", "hooks"}, + }, + "pi": { + Name: "pi", + RulesFamily: "pi", + Status: []string{"rules"}, + }, + } + + return capabilityRegistry{ + families: families, + targets: targets, + } +} + +func ResolveManagedFamily(kind ResourceKind, targetName, targetPath string) (string, bool) { + switch normalizeResourceKind(kind) { + case ResourceKindRules, ResourceKindHooks: + default: + return "", false + } + + cleanName := strings.TrimSpace(targetName) + if cleanName != "" { + if canonical, ok := config.CanonicalTargetName(cleanName); ok { + if classification, ok := managedCapabilities.targets[canonical]; ok { + if family, ok := classification.familyForKind(kind); ok { + return family, true + } + return "", false + } + return "", false + } + + // Preserve compatibility for custom, non-canonical targets that point at + // a native managed path surface intentionally. + for family, spec := range managedCapabilities.families { + if !spec.supportsKind(kind) { + continue + } + if config.MatchesTargetName(family, cleanName) { + return family, true + } + } + } + + family := managedCapabilities.familyForPath(targetPath) + if family == "" { + return "", false + } + if !managedCapabilities.supportsKind(family, kind) { + return "", false + } + return family, true +} + +func CapabilitySnapshot() CapabilitySnapshotPayload { + return CapabilitySnapshotForTargets(config.DefaultTargets()) +} + +func CapabilitySnapshotForTargets(targets map[string]config.TargetConfig) CapabilitySnapshotPayload { + snapshotTargets := make(map[string]TargetClassification, len(targets)) + compatibleTargets := make(map[string][]string, len(managedCapabilities.families)) + for name, target := range targets { + classification := managedCapabilities.classificationForTarget(name, target.SkillsConfig().Path) + snapshotTargets[name] = classification + if classification.RulesFamily != "" { + compatibleTargets[classification.RulesFamily] = append(compatibleTargets[classification.RulesFamily], name) + } + if classification.HooksFamily != "" && classification.HooksFamily != classification.RulesFamily { + compatibleTargets[classification.HooksFamily] = append(compatibleTargets[classification.HooksFamily], name) + } + } + + snapshotFamilies := make(map[string]FamilySpec, len(managedCapabilities.families)) + for name, family := range managedCapabilities.families { + clone := family + clone.CompatibleTargets = dedupeSortedStrings(compatibleTargets[name]) + snapshotFamilies[name] = clone + } + + return CapabilitySnapshotPayload{ + Families: snapshotFamilies, + Targets: snapshotTargets, + } +} + +func normalizeResourceKind(kind ResourceKind) ResourceKind { + switch strings.ToLower(strings.TrimSpace(string(kind))) { + case string(ResourceKindRules): + return ResourceKindRules + case string(ResourceKindHooks): + return ResourceKindHooks + default: + return "" + } +} + +func (r capabilityRegistry) classificationForTarget(name, targetPath string) TargetClassification { + classification := TargetClassification{Name: name} + if explicit, ok := r.targets[name]; ok { + classification = explicit + } + + if classification.RulesFamily == "" { + if family, ok := ResolveManagedFamily(ResourceKindRules, name, targetPath); ok { + classification.RulesFamily = family + } + } + if classification.HooksFamily == "" { + if family, ok := ResolveManagedFamily(ResourceKindHooks, name, targetPath); ok { + classification.HooksFamily = family + } + } + + switch { + case classification.RulesFamily != "" && classification.HooksFamily != "": + classification.Status = []string{"rules", "hooks"} + case classification.RulesFamily != "": + classification.Status = []string{"rules"} + case classification.HooksFamily != "": + classification.Status = []string{"hooks"} + default: + classification.Status = []string{"skills"} + } + return classification +} + +func (r capabilityRegistry) familyForPath(targetPath string) string { + cleaned := filepath.Clean(strings.TrimSpace(targetPath)) + if cleaned == "" || cleaned == "." { + return "" + } + + base := strings.ToLower(filepath.Base(cleaned)) + parent := strings.ToLower(filepath.Base(filepath.Dir(cleaned))) + grandparent := strings.ToLower(filepath.Base(filepath.Dir(filepath.Dir(cleaned)))) + if base == "skills" || base == "agents" || base == "rules" { + if parent == "agent" && grandparent == ".pi" { + return "pi" + } + base = parent + } + + switch base { + case ".claude", "claude": + return "claude" + case ".codex", "codex", ".agents", "agents": + return "codex" + case ".gemini", "gemini": + return "gemini" + case ".pi", "pi": + return "pi" + default: + return "" + } +} + +func (r capabilityRegistry) supportsKind(family string, kind ResourceKind) bool { + spec, ok := r.families[family] + if !ok { + return false + } + switch kind { + case ResourceKindRules: + return spec.SupportsRules + case ResourceKindHooks: + return spec.SupportsHooks + default: + return false + } +} + +func (s FamilySpec) supportsKind(kind ResourceKind) bool { + switch normalizeResourceKind(kind) { + case ResourceKindRules: + return s.SupportsRules + case ResourceKindHooks: + return s.SupportsHooks + default: + return false + } +} + +func (c TargetClassification) familyForKind(kind ResourceKind) (string, bool) { + switch normalizeResourceKind(kind) { + case ResourceKindRules: + if c.RulesFamily != "" { + return c.RulesFamily, true + } + case ResourceKindHooks: + if c.HooksFamily != "" { + return c.HooksFamily, true + } + } + return "", false +} + +func dedupeSortedStrings(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + sort.Strings(out) + return out +} diff --git a/internal/resources/managed/capabilities_test.go b/internal/resources/managed/capabilities_test.go new file mode 100644 index 00000000..9cbb315c --- /dev/null +++ b/internal/resources/managed/capabilities_test.go @@ -0,0 +1,61 @@ +package managed + +import ( + "path/filepath" + "testing" +) + +func TestResolveManagedFamily(t *testing.T) { + cases := []struct { + kind ResourceKind + target string + path string + want string + wantFound bool + }{ + {kind: ResourceKindRules, target: "claude", path: ".claude/skills", want: "claude", wantFound: true}, + {kind: ResourceKindRules, target: "pi", path: ".pi/skills", want: "pi", wantFound: true}, + {kind: ResourceKindRules, target: "pi-sandbox", path: "/tmp/home/.pi/agent/skills", want: "pi", wantFound: true}, + {kind: ResourceKindRules, target: "my-codex", path: filepath.Join("/tmp", "home", ".agents", "skills"), want: "codex", wantFound: true}, + {kind: ResourceKindRules, target: "warp", path: ".agents/skills", wantFound: false}, + {kind: ResourceKindRules, target: "xcode-claude", path: "~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/skills", wantFound: false}, + {kind: ResourceKindHooks, target: "gemini", path: ".gemini/skills", want: "gemini", wantFound: true}, + {kind: ResourceKindHooks, target: "pi", path: ".pi/skills", wantFound: false}, + {kind: ResourceKindHooks, target: "universal", path: ".agents/skills", want: "codex", wantFound: true}, + } + + for _, tc := range cases { + got, found := ResolveManagedFamily(tc.kind, tc.target, tc.path) + if got != tc.want || found != tc.wantFound { + t.Fatalf("%s %s => (%q, %v), want (%q, %v)", tc.kind, tc.target, got, found, tc.want, tc.wantFound) + } + } +} + +func TestCapabilitySnapshot_ContainsExhaustiveTargetClassification(t *testing.T) { + snapshot := CapabilitySnapshot() + if _, ok := snapshot.Targets["claude"]; !ok { + t.Fatal("expected claude classification") + } + if _, ok := snapshot.Targets["pi"]; !ok { + t.Fatal("expected pi classification") + } + if _, ok := snapshot.Targets["windsurf"]; !ok { + t.Fatal("expected windsurf classification") + } + if got := snapshot.Targets["warp"]; got.RulesFamily != "" || got.HooksFamily != "" { + t.Fatalf("warp classification = %#v, want no managed family", got) + } + if got := snapshot.Targets["xcode-claude"]; got.RulesFamily != "" || got.HooksFamily != "" { + t.Fatalf("xcode-claude classification = %#v, want no managed family", got) + } + if got := snapshot.Families["codex"]; containsString(got.CompatibleTargets, "warp") { + t.Fatalf("codex compatible targets = %v, want warp excluded", got.CompatibleTargets) + } + if got := snapshot.Families["claude"]; containsString(got.CompatibleTargets, "xcode-claude") { + t.Fatalf("claude compatible targets = %v, want xcode-claude excluded", got.CompatibleTargets) + } + if got := snapshot.Families["pi"]; !containsString(got.RuleInstructionFiles, "**/AGENTS.md") { + t.Fatalf("pi rule instruction files = %v, want nested AGENTS surface", got.RuleInstructionFiles) + } +} diff --git a/internal/resources/managed/collect.go b/internal/resources/managed/collect.go new file mode 100644 index 00000000..b5a51ba8 --- /dev/null +++ b/internal/resources/managed/collect.go @@ -0,0 +1,95 @@ +package managed + +import ( + "fmt" + + "skillshare/internal/inspect" + managedhooks "skillshare/internal/resources/hooks" + managedrules "skillshare/internal/resources/rules" +) + +// PreviewCollectRules reports which rule items would be collected. +func PreviewCollectRules(projectRoot string, items []inspect.RuleItem, force bool) (CollectPreviewResult, error) { + return previewRuleCollect(projectRoot, items, force) +} + +// CollectRules collects discovered rules into managed storage. +func CollectRules(projectRoot string, items []inspect.RuleItem, strategy managedrules.Strategy) (CollectResult, error) { + result, err := managedrules.Collect(projectRoot, items, managedrules.CollectOptions{Strategy: strategy}) + if err != nil { + return CollectResult{}, fmt.Errorf("collect managed rules: %w", err) + } + return CollectResult{ + Created: append([]string{}, result.Created...), + Overwritten: append([]string{}, result.Overwritten...), + Skipped: append([]string{}, result.Skipped...), + }, nil +} + +// PreviewCollectHooks reports which hook items would be collected. +func PreviewCollectHooks(projectRoot string, items []inspect.HookItem, force bool) (CollectPreviewResult, error) { + return previewHookCollect(projectRoot, items, force) +} + +// CollectHooks collects discovered hooks into managed storage. +func CollectHooks(projectRoot string, items []inspect.HookItem, strategy managedhooks.Strategy) (CollectResult, error) { + result, err := managedhooks.Collect(projectRoot, items, managedhooks.CollectOptions{Strategy: strategy}) + if err != nil { + return CollectResult{}, fmt.Errorf("collect managed hooks: %w", err) + } + return CollectResult{ + Created: append([]string{}, result.Created...), + Overwritten: append([]string{}, result.Overwritten...), + Skipped: append([]string{}, result.Skipped...), + }, nil +} + +func previewRuleCollect(projectRoot string, items []inspect.RuleItem, force bool) (CollectPreviewResult, error) { + strategy := managedrules.StrategySkip + if force { + strategy = managedrules.StrategyOverwrite + } + result, err := managedrules.PreviewCollect(projectRoot, items, managedrules.CollectOptions{Strategy: strategy}) + if err != nil { + return CollectPreviewResult{}, fmt.Errorf("collect managed rules: %w", err) + } + return previewResultFromRuleCollect(result), nil +} + +func previewHookCollect(projectRoot string, items []inspect.HookItem, force bool) (CollectPreviewResult, error) { + strategy := managedhooks.StrategySkip + if force { + strategy = managedhooks.StrategyOverwrite + } + result, err := managedhooks.PreviewCollect(projectRoot, items, managedhooks.CollectOptions{Strategy: strategy}) + if err != nil { + return CollectPreviewResult{}, fmt.Errorf("collect managed hooks: %w", err) + } + return previewResultFromHookCollect(result), nil +} + +func managedRuleCollectID(item inspect.RuleItem) (string, error) { + return managedrules.ManagedIDForDiscoveredRule(item) +} + +func managedHookCollectID(tool, event, matcher string) (string, error) { + return managedhooks.CanonicalRelativePath(tool, event, matcher) +} + +func previewResultFromRuleCollect(result managedrules.CollectResult) CollectPreviewResult { + pulled := append([]string{}, result.Created...) + pulled = append(pulled, result.Overwritten...) + return CollectPreviewResult{ + Pulled: pulled, + Skipped: append([]string{}, result.Skipped...), + } +} + +func previewResultFromHookCollect(result managedhooks.CollectResult) CollectPreviewResult { + pulled := append([]string{}, result.Created...) + pulled = append(pulled, result.Overwritten...) + return CollectPreviewResult{ + Pulled: pulled, + Skipped: append([]string{}, result.Skipped...), + } +} diff --git a/internal/resources/managed/collect_test.go b/internal/resources/managed/collect_test.go new file mode 100644 index 00000000..065c4e76 --- /dev/null +++ b/internal/resources/managed/collect_test.go @@ -0,0 +1,385 @@ +package managed + +import ( + "path/filepath" + "testing" + + "skillshare/internal/inspect" + managedhooks "skillshare/internal/resources/hooks" + managedrules "skillshare/internal/resources/rules" +) + +func TestPreviewCollectRules(t *testing.T) { + projectRoot := t.TempDir() + + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "claude/CLAUDE.md", + Content: []byte("old root\n"), + }); err != nil { + t.Fatalf("seed managed rule: %v", err) + } + if _, err := ruleStore.Put(managedrules.Save{ + ID: "claude/backend.md", + Content: []byte("old backend\n"), + }); err != nil { + t.Fatalf("seed managed rule: %v", err) + } + + items := []inspect.RuleItem{ + { + SourceTool: "claude", + Collectible: true, + Path: filepath.Join(projectRoot, "CLAUDE.md"), + }, + { + SourceTool: "claude", + Collectible: true, + Path: filepath.Join(projectRoot, ".claude", "rules", "backend.md"), + }, + } + + t.Run("skips existing without force", func(t *testing.T) { + result, err := PreviewCollectRules(projectRoot, items, false) + if err != nil { + t.Fatalf("PreviewCollectRules() error = %v", err) + } + + wantRootID, err := managedRuleCollectID(items[0]) + if err != nil { + t.Fatalf("managedRuleCollectID(root) error = %v", err) + } + wantBackendID, err := managedRuleCollectID(items[1]) + if err != nil { + t.Fatalf("managedRuleCollectID(backend) error = %v", err) + } + + if !containsString(result.Skipped, wantRootID) { + t.Fatalf("PreviewCollectRules() skipped = %v, want %q", result.Skipped, wantRootID) + } + if !containsString(result.Skipped, wantBackendID) { + t.Fatalf("PreviewCollectRules() skipped = %v, want %q", result.Skipped, wantBackendID) + } + if len(result.Pulled) != 0 { + t.Fatalf("PreviewCollectRules() pulled = %v, want none", result.Pulled) + } + }) + + t.Run("pulls existing with force", func(t *testing.T) { + result, err := PreviewCollectRules(projectRoot, items, true) + if err != nil { + t.Fatalf("PreviewCollectRules() error = %v", err) + } + + wantRootID, err := managedRuleCollectID(items[0]) + if err != nil { + t.Fatalf("managedRuleCollectID(root) error = %v", err) + } + wantBackendID, err := managedRuleCollectID(items[1]) + if err != nil { + t.Fatalf("managedRuleCollectID(backend) error = %v", err) + } + + if !containsString(result.Pulled, wantRootID) || !containsString(result.Pulled, wantBackendID) { + t.Fatalf("PreviewCollectRules() pulled = %v, want both canonical ids", result.Pulled) + } + }) + + t.Run("rejects canonical collisions", func(t *testing.T) { + colliding := []inspect.RuleItem{ + { + SourceTool: "claude", + Collectible: true, + Path: filepath.Join(projectRoot, "alpha", ".claude", "rules", "backend.md"), + }, + { + SourceTool: "claude", + Collectible: true, + Path: filepath.Join(projectRoot, "beta", ".claude", "rules", "backend.md"), + }, + } + if _, err := PreviewCollectRules(projectRoot, colliding, false); err == nil { + t.Fatal("PreviewCollectRules() error = nil, want canonical collision failure") + } + }) + + t.Run("rejects non collectible rules like collect", func(t *testing.T) { + discovered := []inspect.RuleItem{ + { + SourceTool: "claude", + Collectible: true, + Path: filepath.Join(projectRoot, ".claude", "rules", "backend.md"), + Content: "# Backend\n", + }, + { + SourceTool: "claude", + Collectible: false, + CollectReason: "blocked by policy", + Path: filepath.Join(projectRoot, ".claude", "rules", "blocked.md"), + Content: "# Blocked\n", + }, + } + + previewResult, previewErr := PreviewCollectRules(projectRoot, discovered, true) + if previewErr == nil { + t.Fatalf("PreviewCollectRules() error = nil, want non-collectible failure; result=%#v", previewResult) + } + + collectResult, collectErr := CollectRules(projectRoot, discovered, managedrules.StrategyOverwrite) + if collectErr == nil { + t.Fatalf("CollectRules() error = nil, want non-collectible failure; result=%#v", collectResult) + } + if previewErr.Error() != collectErr.Error() { + t.Fatalf("preview error = %q, collect error = %q, want parity", previewErr.Error(), collectErr.Error()) + } + }) + + t.Run("rejects unsupported pi paths early like collect", func(t *testing.T) { + discovered := []inspect.RuleItem{ + { + SourceTool: "pi", + Collectible: true, + Path: filepath.Join(projectRoot, ".pi", "extra.md"), + Content: "# Extra\n", + }, + } + + previewResult, previewErr := PreviewCollectRules(projectRoot, discovered, true) + if previewErr == nil { + t.Fatalf("PreviewCollectRules() error = nil, want unsupported pi path failure; result=%#v", previewResult) + } + + collectResult, collectErr := CollectRules(projectRoot, discovered, managedrules.StrategyOverwrite) + if collectErr == nil { + t.Fatalf("CollectRules() error = nil, want unsupported pi path failure; result=%#v", collectResult) + } + if previewErr.Error() != collectErr.Error() { + t.Fatalf("preview error = %q, collect error = %q, want parity", previewErr.Error(), collectErr.Error()) + } + }) +} + +func TestPreviewCollectHooks(t *testing.T) { + projectRoot := t.TempDir() + + hookStore := managedhooks.NewStore(projectRoot) + existingID, err := managedHookCollectID("claude", "PreToolUse", ".*") + if err != nil { + t.Fatalf("managedHookCollectID() error = %v", err) + } + if _, err := hookStore.Put(managedhooks.Save{ + ID: existingID, + Tool: "claude", + Event: "PreToolUse", + Matcher: ".*", + Handlers: []managedhooks.Handler{{ + Type: "command", + Command: "old hook", + }}, + }); err != nil { + t.Fatalf("seed managed hook: %v", err) + } + + items := []inspect.HookItem{ + { + GroupID: "group-1", + SourceTool: "claude", + Collectible: true, + Event: "PreToolUse", + Matcher: ".*", + ActionType: "command", + Path: filepath.Join(projectRoot, ".claude", "settings.json"), + }, + } + + result, err := PreviewCollectHooks(projectRoot, items, false) + if err != nil { + t.Fatalf("PreviewCollectHooks() error = %v", err) + } + if !containsString(result.Skipped, existingID) { + t.Fatalf("PreviewCollectHooks() skipped = %v, want %q", result.Skipped, existingID) + } + if len(result.Pulled) != 0 { + t.Fatalf("PreviewCollectHooks() pulled = %v, want none", result.Pulled) + } + + forced, err := PreviewCollectHooks(projectRoot, items, true) + if err != nil { + t.Fatalf("PreviewCollectHooks(force) error = %v", err) + } + if !containsString(forced.Pulled, existingID) { + t.Fatalf("PreviewCollectHooks(force) pulled = %v, want %q", forced.Pulled, existingID) + } + + t.Run("rejects invalid hook grouping like collect", func(t *testing.T) { + discovered := []inspect.HookItem{ + { + GroupID: "group-mismatch", + SourceTool: "claude", + Collectible: true, + Event: "PreToolUse", + Matcher: "Edit", + ActionType: "command", + Command: "./first", + Path: filepath.Join(projectRoot, ".claude", "settings.json"), + }, + { + GroupID: "group-mismatch", + SourceTool: "claude", + Collectible: true, + Event: "PreToolUse", + Matcher: "Bash", + ActionType: "command", + Command: "./second", + Path: filepath.Join(projectRoot, ".claude", "settings.json"), + }, + } + + previewResult, previewErr := PreviewCollectHooks(projectRoot, discovered, true) + if previewErr == nil { + t.Fatalf("PreviewCollectHooks() error = nil, want grouping failure; result=%#v", previewResult) + } + + collectResult, collectErr := CollectHooks(projectRoot, discovered, managedhooks.StrategyOverwrite) + if collectErr == nil { + t.Fatalf("CollectHooks() error = nil, want grouping failure; result=%#v", collectResult) + } + if previewErr.Error() != collectErr.Error() { + t.Fatalf("preview error = %q, collect error = %q, want parity", previewErr.Error(), collectErr.Error()) + } + }) + + t.Run("rejects missing matcher for non codex groups like collect", func(t *testing.T) { + discovered := []inspect.HookItem{ + { + GroupID: "group-missing-matcher", + SourceTool: "claude", + Collectible: true, + Event: "PreToolUse", + Matcher: "", + ActionType: "command", + Command: "./missing-matcher", + Path: filepath.Join(projectRoot, ".claude", "settings.json"), + }, + } + + previewResult, previewErr := PreviewCollectHooks(projectRoot, discovered, true) + if previewErr == nil { + t.Fatalf("PreviewCollectHooks() error = nil, want missing matcher failure; result=%#v", previewResult) + } + + collectResult, collectErr := CollectHooks(projectRoot, discovered, managedhooks.StrategyOverwrite) + if collectErr == nil { + t.Fatalf("CollectHooks() error = nil, want missing matcher failure; result=%#v", collectResult) + } + if previewErr.Error() != collectErr.Error() { + t.Fatalf("preview error = %q, collect error = %q, want parity", previewErr.Error(), collectErr.Error()) + } + }) +} + +func TestCollectRules(t *testing.T) { + projectRoot := t.TempDir() + + store := managedrules.NewStore(projectRoot) + id := "claude/backend.md" + if _, err := store.Put(managedrules.Save{ + ID: id, + Content: []byte("old backend\n"), + }); err != nil { + t.Fatalf("seed managed rule: %v", err) + } + + items := []inspect.RuleItem{ + { + SourceTool: "claude", + Collectible: true, + Path: filepath.Join(projectRoot, ".claude", "rules", "backend.md"), + Content: "new backend\n", + }, + } + + result, err := CollectRules(projectRoot, items, managedrules.StrategyOverwrite) + if err != nil { + t.Fatalf("CollectRules() error = %v", err) + } + + wantID, err := managedRuleCollectID(items[0]) + if err != nil { + t.Fatalf("managedRuleCollectID() error = %v", err) + } + if !containsString(result.Overwritten, wantID) { + t.Fatalf("CollectRules() overwritten = %v, want %q", result.Overwritten, wantID) + } + + record, err := store.Get(wantID) + if err != nil { + t.Fatalf("store.Get() error = %v", err) + } + if string(record.Content) != items[0].Content { + t.Fatalf("store.Get() content = %q, want %q", string(record.Content), items[0].Content) + } +} + +func TestCollectHooks(t *testing.T) { + projectRoot := t.TempDir() + + store := managedhooks.NewStore(projectRoot) + id, err := managedHookCollectID("claude", "PreToolUse", ".*") + if err != nil { + t.Fatalf("managedHookCollectID() error = %v", err) + } + if _, err := store.Put(managedhooks.Save{ + ID: id, + Tool: "claude", + Event: "PreToolUse", + Matcher: ".*", + Handlers: []managedhooks.Handler{{ + Type: "command", + Command: "old hook", + }}, + }); err != nil { + t.Fatalf("seed managed hook: %v", err) + } + + items := []inspect.HookItem{ + { + GroupID: "group-1", + SourceTool: "claude", + Event: "PreToolUse", + Matcher: ".*", + ActionType: "command", + Path: filepath.Join(projectRoot, ".claude", "settings.json"), + Command: "new hook", + Collectible: true, + }, + } + + result, err := CollectHooks(projectRoot, items, managedhooks.StrategyOverwrite) + if err != nil { + t.Fatalf("CollectHooks() error = %v", err) + } + if !containsString(result.Overwritten, id) { + t.Fatalf("CollectHooks() overwritten = %v, want %q", result.Overwritten, id) + } + + record, err := store.Get(id) + if err != nil { + t.Fatalf("store.Get() error = %v", err) + } + if len(record.Handlers) != 1 { + t.Fatalf("store.Get() handlers = %d, want 1", len(record.Handlers)) + } + if record.Handlers[0].Command != "new hook" { + t.Fatalf("store.Get() command = %q, want %q", record.Handlers[0].Command, "new hook") + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/internal/resources/managed/pi/surface.go b/internal/resources/managed/pi/surface.go new file mode 100644 index 00000000..1aaee490 --- /dev/null +++ b/internal/resources/managed/pi/surface.go @@ -0,0 +1,202 @@ +package pi + +import ( + "path" + "path/filepath" + "strings" +) + +const ( + ManagedAgentsID = "pi/AGENTS.md" + ManagedSystemID = "pi/SYSTEM.md" + ManagedAppendSystemID = "pi/APPEND_SYSTEM.md" +) + +var managedRuleSurfaces = []ruleSurface{ + { + id: ManagedAgentsID, + bareName: "AGENTS.md", + instructionFile: "AGENTS.md", + projectCompileRel: "AGENTS.md", + globalCompileRel: "AGENTS.md", + discoveryGlobalRel: ".pi/agent/AGENTS.md", + }, + { + id: ManagedSystemID, + bareName: "SYSTEM.md", + instructionFile: ".pi/SYSTEM.md", + projectCompileRel: ".pi/SYSTEM.md", + globalCompileRel: "SYSTEM.md", + discoveryProjectRel: ".pi/SYSTEM.md", + discoveryGlobalRel: ".pi/agent/SYSTEM.md", + }, + { + id: ManagedAppendSystemID, + bareName: "APPEND_SYSTEM.md", + instructionFile: ".pi/APPEND_SYSTEM.md", + projectCompileRel: ".pi/APPEND_SYSTEM.md", + globalCompileRel: "APPEND_SYSTEM.md", + discoveryProjectRel: ".pi/APPEND_SYSTEM.md", + discoveryGlobalRel: ".pi/agent/APPEND_SYSTEM.md", + }, +} + +type ruleSurface struct { + id string + bareName string + instructionFile string + projectCompileRel string + globalCompileRel string + discoveryProjectRel string + discoveryGlobalRel string +} + +func ManagedRuleIDs() []string { + out := make([]string, 0, len(managedRuleSurfaces)) + for _, surface := range managedRuleSurfaces { + out = append(out, surface.id) + } + return out +} + +func RuleInstructionFiles() []string { + out := make([]string, 0, len(managedRuleSurfaces)+1) + for _, surface := range managedRuleSurfaces { + out = append(out, surface.instructionFile) + } + out = append(out, "**/AGENTS.md") + return out +} + +func IsManagedRuleID(id string) bool { + _, ok := NormalizeManagedRuleID(id) + return ok +} + +func NormalizeManagedRuleID(ref string) (string, bool) { + normalized := path.Clean(strings.ReplaceAll(strings.TrimSpace(ref), "\\", "/")) + for _, surface := range managedRuleSurfaces { + if normalized == surface.id || normalized == surface.bareName { + return surface.id, true + } + } + if nestedAgentsManagedID(normalized) { + return normalized, true + } + return "", false +} + +func ManagedRuleIDForDiscoveredPath(filePath string) (string, bool) { + normalized := strings.ToLower(filepath.ToSlash(strings.TrimSpace(filePath))) + for _, surface := range managedRuleSurfaces { + for _, rel := range []string{surface.discoveryProjectRel, surface.discoveryGlobalRel} { + if rel == "" { + continue + } + normalizedRel := strings.ToLower(filepath.ToSlash(rel)) + if normalized == normalizedRel || strings.HasSuffix(normalized, "/"+normalizedRel) { + return surface.id, true + } + } + } + return "", false +} + +func DiscoveryProjectPaths(projectRoot string) []string { + out := make([]string, 0, len(managedRuleSurfaces)) + for _, surface := range managedRuleSurfaces { + if surface.discoveryProjectRel == "" { + continue + } + out = append(out, filepath.Join(projectRoot, filepath.FromSlash(surface.discoveryProjectRel))) + } + return out +} + +func DiscoveryGlobalPaths(home string) []string { + out := make([]string, 0, len(managedRuleSurfaces)) + for _, surface := range managedRuleSurfaces { + if surface.discoveryGlobalRel == "" { + continue + } + out = append(out, filepath.Join(home, filepath.FromSlash(surface.discoveryGlobalRel))) + } + return out +} + +func OwnedCompilePaths(root string) []string { + ids := []string{ManagedSystemID, ManagedAppendSystemID} + if isGlobalOutputRoot(root) { + ids = append([]string{ManagedAgentsID}, ids...) + } + + out := make([]string, 0, len(ids)) + for _, id := range ids { + if path, ok := CompilePath(root, id); ok { + out = append(out, filepath.Clean(path)) + } + } + return out +} + +func CompilePath(root, id string) (string, bool) { + surface, ok := surfaceByManagedID(id) + if !ok { + if nestedAgentsManagedID(id) { + return filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(path.Clean(strings.ReplaceAll(strings.TrimSpace(id), "\\", "/")), "pi/"))), true + } + return "", false + } + compileRel := surface.projectCompileRel + if isGlobalOutputRoot(root) { + compileRel = surface.globalCompileRel + } + if compileRel == "" { + return "", false + } + return filepath.Join(root, filepath.FromSlash(compileRel)), true +} + +func isGlobalOutputRoot(root string) bool { + cleaned := filepath.Clean(strings.TrimSpace(root)) + if cleaned == "" || cleaned == "." { + return false + } + + base := strings.ToLower(filepath.Base(cleaned)) + parent := strings.ToLower(filepath.Base(filepath.Dir(cleaned))) + return base == ".pi" || (base == "agent" && parent == ".pi") +} + +func surfaceByManagedID(id string) (ruleSurface, bool) { + for _, surface := range managedRuleSurfaces { + if id == surface.id { + return surface, true + } + } + return ruleSurface{}, false +} + +func nestedAgentsManagedID(id string) bool { + normalized := path.Clean(strings.ReplaceAll(strings.TrimSpace(id), "\\", "/")) + if !strings.HasPrefix(normalized, "pi/") { + return false + } + if normalized == ManagedAgentsID { + return false + } + if !strings.HasSuffix(normalized, "/AGENTS.md") { + return false + } + + parts := strings.Split(strings.TrimPrefix(normalized, "pi/"), "/") + if len(parts) < 2 || parts[len(parts)-1] != "AGENTS.md" { + return false + } + for _, part := range parts[:len(parts)-1] { + if part == "" || strings.HasPrefix(part, ".") { + return false + } + } + return true +} diff --git a/internal/resources/managed/sync.go b/internal/resources/managed/sync.go new file mode 100644 index 00000000..41bf52e6 --- /dev/null +++ b/internal/resources/managed/sync.go @@ -0,0 +1,429 @@ +package managed + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "skillshare/internal/config" + "skillshare/internal/resources/adapters" + "skillshare/internal/resources/apply" + managedhooks "skillshare/internal/resources/hooks" + managedpi "skillshare/internal/resources/managed/pi" + managedrules "skillshare/internal/resources/rules" +) + +// Sync runs managed rule and hook sync for each target in the request. +func Sync(req SyncRequest) []SyncResult { + results := make([]SyncResult, 0, len(req.Targets)*2) + for _, target := range req.Targets { + if req.Resources.Rules { + if result, ok := syncRules(req, target); ok { + results = append(results, result) + } + } + if req.Resources.Hooks { + if result, ok := syncHooks(req, target); ok { + results = append(results, result) + } + } + } + return results +} + +func syncRules(req SyncRequest, spec TargetSyncSpec) (SyncResult, bool) { + compileTarget, compileRoot, ok := resolveRuleTarget(spec.Name, spec.Target, req.ProjectRoot) + if !ok { + return SyncResult{}, false + } + + result := SyncResult{Target: spec.Name, Resource: "rules"} + store := managedrules.NewStore(req.ProjectRoot) + records, err := store.List() + if err != nil { + result.Err = fmt.Errorf("list managed rules: %w", err) + return result, true + } + + files, _, err := managedrules.CompileTarget(records, compileTarget, spec.Name, compileRoot) + if err != nil { + if errors.Is(err, managedrules.ErrUnsupportedTarget) { + return SyncResult{}, false + } + result.Err = fmt.Errorf("compile managed rules: %w", err) + return result, true + } + otherCurrentPaths, err := detectRuleOutputConflicts(req, spec, records, compileTarget, files) + if err != nil { + result.Err = fmt.Errorf("compile managed rules: %w", err) + return result, true + } + + updated, skipped, err := apply.CompiledFiles(files, req.DryRun) + if err != nil { + result.Err = fmt.Errorf("apply managed rules: %w", err) + return result, true + } + state, err := loadManagedRuleSyncState(compileRoot) + if err != nil { + result.Err = fmt.Errorf("load managed rule state: %w", err) + return result, true + } + + pruned, err := pruneRuleOrphans(compileTarget, compileRoot, files, otherCurrentPaths, state, req.DryRun) + if err != nil { + result.Err = fmt.Errorf("prune managed rules: %w", err) + return result, true + } + if !req.DryRun { + if err := recordManagedRuleSyncState(spec.Name, compileTarget, compileRoot, files, state); err != nil { + result.Err = fmt.Errorf("save managed rule state: %w", err) + return result, true + } + } + + result.Updated = updated + result.Skipped = skipped + result.Pruned = pruned + return result, true +} + +func syncHooks(req SyncRequest, spec TargetSyncSpec) (SyncResult, bool) { + compileTarget, compileRoot, ok := ResolveHookTarget(spec.Name, spec.Target, req.ProjectRoot) + if !ok { + return SyncResult{}, false + } + + result := SyncResult{Target: spec.Name, Resource: "hooks"} + store := managedhooks.NewStore(req.ProjectRoot) + records, err := store.List() + if err != nil { + result.Err = fmt.Errorf("list managed hooks: %w", err) + return result, true + } + + rawConfig, err := LoadHookRawConfig(compileTarget, compileRoot) + if err != nil { + result.Err = fmt.Errorf("load managed hook config: %w", err) + return result, true + } + + files, _, err := managedhooks.CompileTarget(records, compileTarget, spec.Name, compileRoot, string(rawConfig)) + if err != nil { + if errors.Is(err, managedhooks.ErrUnsupportedTarget) { + return SyncResult{}, false + } + result.Err = fmt.Errorf("compile managed hooks: %w", err) + return result, true + } + + updated, skipped, err := apply.CompiledFiles(files, req.DryRun) + if err != nil { + result.Err = fmt.Errorf("apply managed hooks: %w", err) + return result, true + } + + result.Updated = updated + result.Skipped = skipped + return result, true +} + +func resolveRuleTarget(name string, target config.TargetConfig, projectRoot string) (compileTarget, compileRoot string, ok bool) { + sc := target.SkillsConfig() + family, ok := ResolveManagedFamily(ResourceKindRules, name, sc.Path) + if !ok { + return "", "", false + } + if projectRoot != "" { + return family, projectRoot, true + } + return family, RuleGlobalPreviewRoot(sc.Path), true +} + +// ResolveHookTarget resolves a managed hook target to its native family and compile root. +func ResolveHookTarget(name string, target config.TargetConfig, projectRoot string) (compileTarget, compileRoot string, ok bool) { + sc := target.SkillsConfig() + family, ok := ResolveManagedFamily(ResourceKindHooks, name, sc.Path) + if !ok { + return "", "", false + } + if projectRoot != "" { + return family, projectRoot, true + } + return family, managedHookGlobalPreviewRoot(sc.Path), true +} + +func RuleGlobalPreviewRoot(targetPath string) string { + cleaned := filepath.Clean(strings.TrimSpace(targetPath)) + if cleaned == "" || cleaned == "." { + return targetPath + } + if strings.EqualFold(filepath.Base(cleaned), "skills") { + return filepath.Dir(cleaned) + } + return cleaned +} + +func managedHookGlobalPreviewRoot(targetPath string) string { + cleaned := filepath.Clean(strings.TrimSpace(targetPath)) + if cleaned == "" || cleaned == "." { + return targetPath + } + if strings.EqualFold(filepath.Base(cleaned), "skills") { + cleaned = filepath.Dir(cleaned) + } + + switch strings.ToLower(filepath.Base(cleaned)) { + case ".claude", "claude", ".codex", "codex", ".agents", "agents", ".gemini", "gemini": + return filepath.Dir(cleaned) + default: + return cleaned + } +} + +// LoadHookRawConfig reads the native hook config file for the target family, if any. +func LoadHookRawConfig(compileTarget, compileRoot string) ([]byte, error) { + path, ok := managedHookConfigPath(compileTarget, compileRoot) + if !ok { + return nil, nil + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + return data, nil +} + +func managedHookConfigPath(target, root string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(target)) { + case "claude": + return filepath.Join(root, ".claude", "settings.json"), true + case "codex": + return filepath.Join(root, ".codex", "config.toml"), true + case "gemini": + return filepath.Join(root, ".gemini", "settings.json"), true + default: + return "", false + } +} + +func managedRuleOwnedFiles(target, root string) []string { + switch strings.ToLower(strings.TrimSpace(target)) { + case "pi": + return managedpi.OwnedCompilePaths(root) + default: + return nil + } +} + +func detectRuleOutputConflicts(req SyncRequest, current TargetSyncSpec, records []managedrules.Record, currentFamily string, currentFiles []adapters.CompiledFile) (map[string]struct{}, error) { + currentPaths := make(map[string]string, len(currentFiles)) + for _, file := range currentFiles { + currentPaths[filepath.Clean(file.Path)] = file.Content + } + otherCurrentPaths := make(map[string]struct{}) + + for _, other := range conflictAnalysisTargets(req) { + if other.Name == current.Name { + continue + } + otherFamily, otherRoot, ok := resolveRuleTarget(other.Name, other.Target, req.ProjectRoot) + if !ok { + continue + } + + otherFiles, _, err := managedrules.CompileTarget(records, otherFamily, other.Name, otherRoot) + if err != nil { + if errors.Is(err, managedrules.ErrUnsupportedTarget) { + continue + } + return nil, err + } + for _, otherFile := range otherFiles { + cleaned := filepath.Clean(otherFile.Path) + otherCurrentPaths[cleaned] = struct{}{} + if currentContent, ok := currentPaths[cleaned]; ok && currentContent != otherFile.Content { + return nil, fmt.Errorf("managed rule output conflict: %s is produced by %s (%s) and %s (%s)", cleaned, current.Name, currentFamily, other.Name, otherFamily) + } + } + } + + return otherCurrentPaths, nil +} + +func conflictAnalysisTargets(req SyncRequest) []TargetSyncSpec { + if len(req.AllTargets) > 0 { + return req.AllTargets + } + return req.Targets +} + +func pruneRuleOrphans(target, root string, files []adapters.CompiledFile, otherCurrentPaths map[string]struct{}, state *managedRuleSyncState, dryRun bool) ([]string, error) { + ownedDir, ok := managedRuleOwnedDir(target, root) + ownedFiles := managedRuleOwnedFiles(target, root) + if !ok && len(ownedFiles) == 0 && !managedRuleHasTrackedOutputs(state) { + return []string{}, nil + } + + if ok { + info, err := os.Stat(ownedDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + ok = false + } else { + return nil, err + } + } + if ok && !info.IsDir() { + return nil, fmt.Errorf("managed rules path is not a directory: %s", ownedDir) + } + } + + keep := make(map[string]struct{}, len(files)) + for _, file := range files { + if ok && pathWithinDir(file.Path, ownedDir) { + keep[filepath.Clean(file.Path)] = struct{}{} + } + for _, ownedPath := range ownedFiles { + if filepath.Clean(file.Path) == ownedPath { + keep[ownedPath] = struct{}{} + } + } + } + for _, output := range managedRuleTrackedOutputs("", target, root, files) { + keep[filepath.Clean(output.Path)] = struct{}{} + } + for path := range otherCurrentPaths { + keep[path] = struct{}{} + } + + pruned := make([]string, 0) + if ok { + if err := filepath.WalkDir(ownedDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == ownedDir || d.IsDir() { + return nil + } + + cleaned := filepath.Clean(path) + if _, ok := keep[cleaned]; ok { + return nil + } + + pruned = append(pruned, cleaned) + if dryRun { + return nil + } + return os.Remove(cleaned) + }); err != nil { + return nil, err + } + } + if err := pruneTrackedManagedRuleOutputs(root, keep, state, dryRun, &pruned); err != nil { + return nil, err + } + for _, ownedPath := range ownedFiles { + if _, ok := keep[ownedPath]; ok { + continue + } + info, err := os.Stat(ownedPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return nil, err + } + if info.IsDir() { + continue + } + + pruned = append(pruned, ownedPath) + if dryRun { + continue + } + if err := os.Remove(ownedPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + } + + if dryRun { + return pruned, nil + } + if !ok { + return pruned, nil + } + return pruned, removeEmptyRuleSubdirs(ownedDir) +} + +func managedRuleOwnedDir(target, root string) (string, bool) { + cleaned := filepath.Clean(strings.TrimSpace(root)) + switch strings.ToLower(strings.TrimSpace(target)) { + case "claude": + if strings.EqualFold(filepath.Base(cleaned), ".claude") { + return filepath.Join(cleaned, "rules"), true + } + return filepath.Join(cleaned, ".claude", "rules"), true + case "gemini": + if strings.EqualFold(filepath.Base(cleaned), ".gemini") { + return filepath.Join(cleaned, "rules"), true + } + return filepath.Join(cleaned, ".gemini", "rules"), true + default: + return "", false + } +} + +func removeEmptyRuleSubdirs(root string) error { + var dirs []string + if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && path != root { + dirs = append(dirs, path) + } + return nil + }); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + + sort.Slice(dirs, func(i, j int) bool { + return len(dirs[i]) > len(dirs[j]) + }) + + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return err + } + if len(entries) == 0 { + if err := os.Remove(dir); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + } + return nil +} + +func pathWithinDir(path, dir string) bool { + rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(path)) + if err != nil { + return false + } + return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} diff --git a/internal/resources/managed/sync_state.go b/internal/resources/managed/sync_state.go new file mode 100644 index 00000000..0684ec28 --- /dev/null +++ b/internal/resources/managed/sync_state.go @@ -0,0 +1,292 @@ +package managed + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "skillshare/internal/config" + "skillshare/internal/resources/adapters" +) + +type managedRuleSyncState struct { + Outputs map[string]managedRuleSyncOutput `json:"outputs,omitempty"` +} + +type managedRuleSyncOutput struct { + Target string `json:"target,omitempty"` + Path string `json:"path,omitempty"` + Checksum string `json:"checksum,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +func loadManagedRuleSyncState(root string) (*managedRuleSyncState, error) { + state := &managedRuleSyncState{Outputs: map[string]managedRuleSyncOutput{}} + if strings.TrimSpace(root) == "" { + return state, nil + } + + path := managedRuleSyncStatePath(root) + data, err := os.ReadFile(path) + if err != nil { + if errorsIsNotExist(err) { + return state, nil + } + return nil, err + } + + if err := json.Unmarshal(data, state); err != nil { + return &managedRuleSyncState{Outputs: map[string]managedRuleSyncOutput{}}, nil + } + normalizeManagedRuleSyncState(root, state) + return state, nil +} + +func recordManagedRuleSyncState(targetName, family, root string, files []adapters.CompiledFile, state *managedRuleSyncState) error { + if strings.TrimSpace(root) == "" || state == nil { + return nil + } + if state.Outputs == nil { + state.Outputs = map[string]managedRuleSyncOutput{} + } + targetName = strings.TrimSpace(targetName) + if targetName == "" { + return nil + } + + clearManagedRuleTrackedOutputsForTarget(state, targetName) + for _, output := range managedRuleTrackedOutputs(targetName, family, root, files) { + state.Outputs[managedRuleSyncOutputKey(output.Target, output.Path)] = output + } + return saveManagedRuleSyncState(root, state) +} + +func managedRuleHasTrackedOutputs(state *managedRuleSyncState) bool { + return state != nil && len(state.Outputs) > 0 +} + +func pruneTrackedManagedRuleOutputs(root string, keep map[string]struct{}, state *managedRuleSyncState, dryRun bool, pruned *[]string) error { + if state == nil || len(state.Outputs) == 0 { + return nil + } + + changed := false + for path, claims := range managedRuleTrackedOutputsByPath(state) { + if _, ok := keep[path]; ok { + continue + } + + data, err := os.ReadFile(path) + if err != nil { + if errorsIsNotExist(err) { + if !dryRun { + clearManagedRuleTrackedOutputsForPath(state, path) + changed = true + } + continue + } + return err + } + + currentChecksum := checksumForContent(data) + claimed := false + for _, claim := range claims { + if claim.Checksum == currentChecksum { + claimed = true + break + } + } + if !claimed { + if !dryRun { + clearManagedRuleTrackedOutputsForPath(state, path) + changed = true + } + continue + } + + *pruned = append(*pruned, path) + if dryRun { + continue + } + if err := os.Remove(path); err != nil && !errorsIsNotExist(err) { + return err + } + clearManagedRuleTrackedOutputsForPath(state, path) + changed = true + } + + if !changed || dryRun { + return nil + } + + return saveManagedRuleSyncState(root, state) +} + +func saveManagedRuleSyncState(root string, state *managedRuleSyncState) error { + if strings.TrimSpace(root) == "" || state == nil { + return nil + } + normalizeManagedRuleSyncState(root, state) + if len(state.Outputs) == 0 { + err := os.Remove(managedRuleSyncStatePath(root)) + if err != nil && !errorsIsNotExist(err) { + return err + } + return nil + } + + path := managedRuleSyncStatePath(root) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + }() + + if _, err := tmp.Write(data); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + return nil +} + +func managedRuleSyncStatePath(root string) string { + cleaned := filepath.Clean(strings.TrimSpace(root)) + sum := sha256.Sum256([]byte(cleaned)) + return filepath.Join(config.StateDir(), "managed", "rules", hex.EncodeToString(sum[:])+".json") +} + +func checksumForContent(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +func errorsIsNotExist(err error) bool { + return err != nil && os.IsNotExist(err) +} + +func normalizeManagedRuleSyncState(root string, state *managedRuleSyncState) { + if state.Outputs == nil { + state.Outputs = map[string]managedRuleSyncOutput{} + } + + normalized := make(map[string]managedRuleSyncOutput, len(state.Outputs)) + rootAgentsPath := filepath.Clean(filepath.Join(root, "AGENTS.md")) + for key, output := range state.Outputs { + target := strings.TrimSpace(output.Target) + rawPath := strings.TrimSpace(output.Path) + path := "" + if rawPath != "" { + path = filepath.Clean(rawPath) + } + switch { + case target == "" && key == "AGENTS.md": + target = "legacy" + path = rootAgentsPath + case target == "" && path == "": + target = strings.TrimSpace(key) + path = rootAgentsPath + case path == "": + path = rootAgentsPath + } + if target == "" || path == "" || output.Checksum == "" { + continue + } + output.Target = target + output.Path = path + normalized[managedRuleSyncOutputKey(target, path)] = output + } + state.Outputs = normalized +} + +func managedRuleSyncOutputKey(target, path string) string { + return strings.TrimSpace(target) + "\x00" + filepath.Clean(strings.TrimSpace(path)) +} + +func managedRuleTrackedOutputs(targetName, family, root string, files []adapters.CompiledFile) []managedRuleSyncOutput { + ownedDir, hasOwnedDir := managedRuleOwnedDir(family, root) + ownedFileSet := make(map[string]struct{}) + for _, path := range managedRuleOwnedFiles(family, root) { + ownedFileSet[filepath.Clean(path)] = struct{}{} + } + + outputs := make([]managedRuleSyncOutput, 0, len(files)) + for _, file := range files { + cleanedPath := filepath.Clean(file.Path) + if hasOwnedDir && pathWithinDir(cleanedPath, ownedDir) { + continue + } + if _, ok := ownedFileSet[cleanedPath]; ok { + continue + } + outputs = append(outputs, managedRuleSyncOutput{ + Target: strings.TrimSpace(targetName), + Path: cleanedPath, + Checksum: checksumForContent([]byte(file.Content)), + UpdatedAt: time.Now().UTC(), + }) + } + return outputs +} + +func managedRuleTrackedOutputsByPath(state *managedRuleSyncState) map[string][]managedRuleSyncOutput { + byPath := make(map[string][]managedRuleSyncOutput) + if state == nil { + return byPath + } + for _, output := range state.Outputs { + if output.Path == "" || output.Checksum == "" { + continue + } + path := filepath.Clean(output.Path) + byPath[path] = append(byPath[path], output) + } + return byPath +} + +func clearManagedRuleTrackedOutputsForTarget(state *managedRuleSyncState, target string) { + if state == nil { + return + } + target = strings.TrimSpace(target) + for key, output := range state.Outputs { + if output.Target != target { + continue + } + delete(state.Outputs, key) + } +} + +func clearManagedRuleTrackedOutputsForPath(state *managedRuleSyncState, path string) { + if state == nil { + return + } + path = filepath.Clean(strings.TrimSpace(path)) + for key, output := range state.Outputs { + if filepath.Clean(output.Path) != path { + continue + } + delete(state.Outputs, key) + } +} diff --git a/internal/resources/managed/sync_state_test.go b/internal/resources/managed/sync_state_test.go new file mode 100644 index 00000000..fe45b7e4 --- /dev/null +++ b/internal/resources/managed/sync_state_test.go @@ -0,0 +1,92 @@ +package managed + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestPruneTrackedManagedRuleOutputs_RemovesCompileRootStateAfterLastClaim(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + + root := t.TempDir() + nestedAgentsPath := filepath.Join(root, "nested", "AGENTS.md") + if err := os.MkdirAll(filepath.Dir(nestedAgentsPath), 0o755); err != nil { + t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(nestedAgentsPath), err) + } + + content := []byte("# Nested Pi Agents\n") + if err := os.WriteFile(nestedAgentsPath, content, 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", nestedAgentsPath, err) + } + + state := &managedRuleSyncState{ + Outputs: map[string]managedRuleSyncOutput{ + managedRuleSyncOutputKey("pi", nestedAgentsPath): { + Target: "pi", + Path: nestedAgentsPath, + Checksum: checksumForContent(content), + }, + }, + } + if err := saveManagedRuleSyncState(root, state); err != nil { + t.Fatalf("saveManagedRuleSyncState() error = %v", err) + } + if _, err := os.Stat(managedRuleSyncStatePath(root)); err != nil { + t.Fatalf("expected state file before prune, got err=%v", err) + } + + pruned := make([]string, 0, 1) + if err := pruneTrackedManagedRuleOutputs(root, map[string]struct{}{}, state, false, &pruned); err != nil { + t.Fatalf("pruneTrackedManagedRuleOutputs() error = %v", err) + } + + if !containsAll(pruned, nestedAgentsPath) { + t.Fatalf("pruned = %v, want %q", pruned, nestedAgentsPath) + } + if _, err := os.Stat(managedRuleSyncStatePath(root)); !os.IsNotExist(err) { + t.Fatalf("expected compile-root state file to be removed, got err=%v", err) + } +} + +func TestLoadManagedRuleSyncState_MigratesLegacyPathlessClaimsToProjectRootAgents(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + + root := t.TempDir() + legacy := map[string]any{ + "outputs": map[string]any{ + "pi": map[string]any{ + "target": "pi", + "checksum": "legacy-checksum", + }, + }, + } + + data, err := json.Marshal(legacy) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(managedRuleSyncStatePath(root)), 0o755); err != nil { + t.Fatalf("MkdirAll(state dir) error = %v", err) + } + if err := os.WriteFile(managedRuleSyncStatePath(root), data, 0o644); err != nil { + t.Fatalf("WriteFile(state) error = %v", err) + } + + state, err := loadManagedRuleSyncState(root) + if err != nil { + t.Fatalf("loadManagedRuleSyncState() error = %v", err) + } + + wantPath := filepath.Join(root, "AGENTS.md") + output, ok := state.Outputs[managedRuleSyncOutputKey("pi", wantPath)] + if !ok { + t.Fatalf("state outputs = %#v, want migrated claim for %q", state.Outputs, wantPath) + } + if output.Path != wantPath { + t.Fatalf("output.Path = %q, want %q", output.Path, wantPath) + } +} diff --git a/internal/resources/managed/sync_test.go b/internal/resources/managed/sync_test.go new file mode 100644 index 00000000..41a1107e --- /dev/null +++ b/internal/resources/managed/sync_test.go @@ -0,0 +1,1120 @@ +package managed + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "skillshare/internal/config" + managedhooks "skillshare/internal/resources/hooks" + managedrules "skillshare/internal/resources/rules" +) + +func TestResolveHookTarget_UsesManagedFamilyAndPreviewRoot(t *testing.T) { + t.Run("project codex-compatible target", func(t *testing.T) { + target := config.TargetConfig{Path: filepath.Join("/tmp", "home", ".agents", "skills")} + + compileTarget, compileRoot, ok := ResolveHookTarget("universal", target, "/repo") + if !ok { + t.Fatal("ResolveHookTarget() ok = false, want true") + } + if compileTarget != "codex" { + t.Fatalf("ResolveHookTarget() target = %q, want %q", compileTarget, "codex") + } + if compileRoot != "/repo" { + t.Fatalf("ResolveHookTarget() root = %q, want %q", compileRoot, "/repo") + } + }) + + t.Run("global gemini target", func(t *testing.T) { + target := config.TargetConfig{Path: filepath.Join("/tmp", "home", ".gemini", "skills")} + + compileTarget, compileRoot, ok := ResolveHookTarget("gemini", target, "") + if !ok { + t.Fatal("ResolveHookTarget() ok = false, want true") + } + if compileTarget != "gemini" { + t.Fatalf("ResolveHookTarget() target = %q, want %q", compileTarget, "gemini") + } + if compileRoot != filepath.Join("/tmp", "home") { + t.Fatalf("ResolveHookTarget() root = %q, want %q", compileRoot, filepath.Join("/tmp", "home")) + } + }) + + t.Run("unsupported target", func(t *testing.T) { + target := config.TargetConfig{Path: filepath.Join("/tmp", "home", ".cursor", "rules")} + + compileTarget, compileRoot, ok := ResolveHookTarget("cursor", target, "") + if ok { + t.Fatalf("ResolveHookTarget() = (%q, %q, %v), want unsupported target", compileTarget, compileRoot, ok) + } + }) +} + +func TestLoadHookRawConfig_UsesNativeFamilyConfigFiles(t *testing.T) { + root := t.TempDir() + + codexPath := filepath.Join(root, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(codexPath), 0o755); err != nil { + t.Fatalf("mkdir codex config dir: %v", err) + } + if err := os.WriteFile(codexPath, []byte("model = \"gpt-5\"\n"), 0o644); err != nil { + t.Fatalf("write codex config: %v", err) + } + + rawConfig, err := LoadHookRawConfig("codex", root) + if err != nil { + t.Fatalf("LoadHookRawConfig() error = %v", err) + } + if string(rawConfig) != "model = \"gpt-5\"\n" { + t.Fatalf("LoadHookRawConfig() = %q, want codex config contents", string(rawConfig)) + } + + emptyConfig, err := LoadHookRawConfig("pi", root) + if err != nil { + t.Fatalf("LoadHookRawConfig(unsupported) error = %v", err) + } + if emptyConfig != nil { + t.Fatalf("LoadHookRawConfig(unsupported) = %q, want nil", string(emptyConfig)) + } +} + +func TestSync_SyncsRulesAndHooksForClaudeTarget(t *testing.T) { + projectRoot := t.TempDir() + ensureClaudeTargetFiles(t, projectRoot) + + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "claude/backend.md", + Content: []byte("# Backend\n"), + }); err != nil { + t.Fatalf("put managed rule: %v", err) + } + + hookStore := managedhooks.NewStore(projectRoot) + hookID, err := managedhooks.CanonicalRelativePath("claude", "PreToolUse", ".*") + if err != nil { + t.Fatalf("canonical hook id: %v", err) + } + if _, err := hookStore.Put(managedhooks.Save{ + ID: hookID, + Tool: "claude", + Event: "PreToolUse", + Matcher: ".*", + Handlers: []managedhooks.Handler{{ + Type: "command", + Command: "echo hook", + }}, + }); err != nil { + t.Fatalf("put managed hook: %v", err) + } + + results := Sync(SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true, Hooks: true}, + Targets: []TargetSyncSpec{{ + Name: "claude", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".claude", "skills")}, + }}, + }) + + if got := len(results); got != 2 { + t.Fatalf("Sync() results len = %d, want 2", got) + } + + ruleResult := findSyncResult(t, results, "claude", "rules") + if ruleResult.Err != nil { + t.Fatalf("rules sync error = %v", ruleResult.Err) + } + if !containsAll(ruleResult.Updated, filepath.Join(projectRoot, ".claude", "rules", "backend.md")) { + t.Fatalf("rules updated = %v, want backend rule output", ruleResult.Updated) + } + + hookResult := findSyncResult(t, results, "claude", "hooks") + if hookResult.Err != nil { + t.Fatalf("hooks sync error = %v", hookResult.Err) + } + if !containsAll(hookResult.Updated, filepath.Join(projectRoot, ".claude", "settings.json")) { + t.Fatalf("hooks updated = %v, want settings.json", hookResult.Updated) + } + + compiledRule := readFile(t, filepath.Join(projectRoot, ".claude", "rules", "backend.md")) + if !strings.Contains(compiledRule, "# Backend") { + t.Fatalf("compiled rule content = %q, want backend content", compiledRule) + } + compiledHook := readFile(t, filepath.Join(projectRoot, ".claude", "settings.json")) + if !strings.Contains(compiledHook, `"hooks"`) { + t.Fatalf("compiled hook content = %q, want hooks section", compiledHook) + } +} + +func TestSync_ContinuesToHooksAfterRuleFailure(t *testing.T) { + projectRoot := t.TempDir() + ensureClaudeTargetFiles(t, projectRoot) + + blocker := filepath.Join(projectRoot, ".claude", "rules") + if err := os.WriteFile(blocker, []byte("block rules directory creation"), 0o644); err != nil { + t.Fatalf("create blocker file: %v", err) + } + + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "claude/backend.md", + Content: []byte("# Backend\n"), + }); err != nil { + t.Fatalf("put managed rule: %v", err) + } + + hookStore := managedhooks.NewStore(projectRoot) + hookID, err := managedhooks.CanonicalRelativePath("claude", "PreToolUse", ".*") + if err != nil { + t.Fatalf("canonical hook id: %v", err) + } + if _, err := hookStore.Put(managedhooks.Save{ + ID: hookID, + Tool: "claude", + Event: "PreToolUse", + Matcher: ".*", + Handlers: []managedhooks.Handler{{ + Type: "command", + Command: "echo hook", + }}, + }); err != nil { + t.Fatalf("put managed hook: %v", err) + } + + results := Sync(SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true, Hooks: true}, + Targets: []TargetSyncSpec{{ + Name: "claude", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".claude", "skills")}, + }}, + }) + + ruleResult := findSyncResult(t, results, "claude", "rules") + if ruleResult.Err == nil { + t.Fatal("rules sync error = nil, want failure") + } + + hookResult := findSyncResult(t, results, "claude", "hooks") + if hookResult.Err != nil { + t.Fatalf("hooks sync error = %v, want nil", hookResult.Err) + } + if !containsAll(hookResult.Updated, filepath.Join(projectRoot, ".claude", "settings.json")) { + t.Fatalf("hooks updated = %v, want settings.json", hookResult.Updated) + } +} + +func TestSync_HonorsAssignedTargetsAndDisabledState(t *testing.T) { + xdgHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgHome) + + workRoot := filepath.Join(t.TempDir(), "work") + personalRoot := filepath.Join(t.TempDir(), "personal") + ensureClaudeTargetFiles(t, workRoot) + ensureClaudeTargetFiles(t, personalRoot) + + ruleStore := managedrules.NewStore("") + if _, err := ruleStore.Put(managedrules.Save{ + ID: "claude/backend.md", + Content: []byte("# Backend\n"), + Targets: []string{"claude-work"}, + SourceType: "local", + }); err != nil { + t.Fatalf("put managed rule: %v", err) + } + + hookStore := managedhooks.NewStore("") + hookID, err := managedhooks.CanonicalRelativePath("claude", "PreToolUse", "Bash") + if err != nil { + t.Fatalf("canonical hook id: %v", err) + } + if _, err := hookStore.Put(managedhooks.Save{ + ID: hookID, + Tool: "claude", + Event: "PreToolUse", + Matcher: "Bash", + Targets: []string{"claude-work"}, + SourceType: "local", + Disabled: true, + Handlers: []managedhooks.Handler{{ + Type: "command", + Command: "echo hook", + }}, + }); err != nil { + t.Fatalf("put managed hook: %v", err) + } + + results := Sync(SyncRequest{ + DryRun: false, + Resources: ResourceSet{Rules: true, Hooks: true}, + Targets: []TargetSyncSpec{ + { + Name: "claude-work", + Target: config.TargetConfig{Path: filepath.Join(workRoot, ".claude", "skills")}, + }, + { + Name: "claude-personal", + Target: config.TargetConfig{Path: filepath.Join(personalRoot, ".claude", "skills")}, + }, + }, + }) + + workRuleResult := findSyncResult(t, results, "claude-work", "rules") + if workRuleResult.Err != nil { + t.Fatalf("work rules sync error = %v", workRuleResult.Err) + } + if !containsAll(workRuleResult.Updated, filepath.Join(workRoot, ".claude", "rules", "backend.md")) { + t.Fatalf("work rules updated = %v, want backend rule output", workRuleResult.Updated) + } + + personalRuleResult := findSyncResult(t, results, "claude-personal", "rules") + if personalRuleResult.Err != nil { + t.Fatalf("personal rules sync error = %v", personalRuleResult.Err) + } + if len(personalRuleResult.Updated) != 0 { + t.Fatalf("personal rules updated = %v, want none", personalRuleResult.Updated) + } + + workHookResult := findSyncResult(t, results, "claude-work", "hooks") + if workHookResult.Err != nil { + t.Fatalf("work hooks sync error = %v", workHookResult.Err) + } + if !containsAll(workHookResult.Updated, filepath.Join(workRoot, ".claude", "settings.json")) { + t.Fatalf("work hooks updated = %v, want empty carrier settings output", workHookResult.Updated) + } + + if _, err := os.Stat(filepath.Join(workRoot, ".claude", "rules", "backend.md")); err != nil { + t.Fatalf("expected work rule output: %v", err) + } + if _, err := os.Stat(filepath.Join(personalRoot, ".claude", "rules", "backend.md")); !os.IsNotExist(err) { + t.Fatalf("expected no personal rule output, got err=%v", err) + } + workHookConfig := readFile(t, filepath.Join(workRoot, ".claude", "settings.json")) + if strings.Contains(workHookConfig, "echo hook") { + t.Fatalf("work hook config = %q, want disabled hook omitted", workHookConfig) + } +} + +func TestSync_SyncsGeminiHooksAndPreservesExistingSettings(t *testing.T) { + projectRoot := t.TempDir() + if err := os.MkdirAll(filepath.Join(projectRoot, ".gemini"), 0o755); err != nil { + t.Fatalf("mkdir .gemini: %v", err) + } + if err := os.WriteFile(filepath.Join(projectRoot, ".gemini", "settings.json"), []byte(`{"theme":"light"}`), 0o644); err != nil { + t.Fatalf("write gemini settings: %v", err) + } + + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "gemini/backend.md", + Content: []byte("# Backend\n"), + }); err != nil { + t.Fatalf("put managed gemini rule: %v", err) + } + hookStore := managedhooks.NewStore(projectRoot) + hookID, err := managedhooks.CanonicalRelativePath("gemini", "BeforeTool", "Read") + if err != nil { + t.Fatalf("canonical gemini hook id: %v", err) + } + sequential := true + if _, err := hookStore.Put(managedhooks.Save{ + ID: hookID, + Tool: "gemini", + Event: "BeforeTool", + Matcher: "Read", + Sequential: &sequential, + Handlers: []managedhooks.Handler{{ + Type: "command", + Name: "lint-read", + Description: "Run read lint", + Command: "./bin/gemini-lint", + Timeout: "30000", + }}, + }); err != nil { + t.Fatalf("put managed gemini hook: %v", err) + } + + results := Sync(SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true, Hooks: true}, + Targets: []TargetSyncSpec{{ + Name: "gemini", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".gemini", "skills")}, + }}, + }) + + if got := len(results); got != 2 { + t.Fatalf("Sync() results len = %d, want rule and hook results", got) + } + + ruleResult := findSyncResult(t, results, "gemini", "rules") + if ruleResult.Err != nil { + t.Fatalf("gemini rules sync error = %v", ruleResult.Err) + } + if !containsAll(ruleResult.Updated, filepath.Join(projectRoot, ".gemini", "rules", "backend.md")) { + t.Fatalf("gemini rules updated = %v, want backend rule output", ruleResult.Updated) + } + + hookResult := findSyncResult(t, results, "gemini", "hooks") + if hookResult.Err != nil { + t.Fatalf("gemini hooks sync error = %v", hookResult.Err) + } + if !containsAll(hookResult.Updated, filepath.Join(projectRoot, ".gemini", "settings.json")) { + t.Fatalf("gemini hooks updated = %v, want settings.json", hookResult.Updated) + } + + compiledHook := readFile(t, filepath.Join(projectRoot, ".gemini", "settings.json")) + for _, want := range []string{`"theme":"light"`, `"BeforeTool"`, `"sequential":true`, `"name":"lint-read"`, `"description":"Run read lint"`} { + if !strings.Contains(compiledHook, want) { + t.Fatalf("compiled gemini hook content missing %q: %q", want, compiledHook) + } + } +} + +func TestSync_PrunesDeletedPiRuleOutputs(t *testing.T) { + xdgHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgHome) + + homeRoot := filepath.Join(t.TempDir(), "home") + ruleStore := managedrules.NewStore("") + if _, err := ruleStore.Put(managedrules.Save{ + ID: "pi/SYSTEM.md", + Content: []byte("# Pi System\n"), + }); err != nil { + t.Fatalf("put managed pi rule: %v", err) + } + + targetPath := filepath.Join(homeRoot, ".pi", "agent", "skills") + req := SyncRequest{ + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{{ + Name: "pi", + Target: config.TargetConfig{Path: targetPath}, + }}, + } + + first := Sync(req) + firstResult := findSyncResult(t, first, "pi", "rules") + if firstResult.Err != nil { + t.Fatalf("first pi rules sync error = %v", firstResult.Err) + } + + compiledPath := filepath.Join(homeRoot, ".pi", "agent", "SYSTEM.md") + if _, err := os.Stat(compiledPath); err != nil { + t.Fatalf("expected compiled pi rule at %s: %v", compiledPath, err) + } + + if err := ruleStore.Delete("pi/SYSTEM.md"); err != nil { + t.Fatalf("delete managed pi rule: %v", err) + } + + second := Sync(req) + secondResult := findSyncResult(t, second, "pi", "rules") + if secondResult.Err != nil { + t.Fatalf("second pi rules sync error = %v", secondResult.Err) + } + if !containsAll(secondResult.Pruned, compiledPath) { + t.Fatalf("second pi rules pruned = %v, want %q", secondResult.Pruned, compiledPath) + } + if _, err := os.Stat(compiledPath); !os.IsNotExist(err) { + t.Fatalf("expected compiled pi rule to be pruned, got err=%v", err) + } +} + +func TestSync_PrunesManagedProjectRootAgentsAfterPiSourceDeletion(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + + projectRoot := t.TempDir() + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "pi/AGENTS.md", + Content: []byte("# Pi Agents\n"), + }); err != nil { + t.Fatalf("put managed pi agents: %v", err) + } + + req := SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{{ + Name: "pi", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".pi", "skills")}, + }}, + } + + first := Sync(req) + firstResult := findSyncResult(t, first, "pi", "rules") + if firstResult.Err != nil { + t.Fatalf("first pi rules sync error = %v", firstResult.Err) + } + managedAgentsPath := filepath.Join(projectRoot, "AGENTS.md") + if _, err := os.Stat(managedAgentsPath); err != nil { + t.Fatalf("expected managed AGENTS.md after first sync: %v", err) + } + + if err := ruleStore.Delete("pi/AGENTS.md"); err != nil { + t.Fatalf("delete managed pi agents: %v", err) + } + + second := Sync(req) + secondResult := findSyncResult(t, second, "pi", "rules") + if secondResult.Err != nil { + t.Fatalf("second pi rules sync error = %v", secondResult.Err) + } + if !containsAll(secondResult.Pruned, managedAgentsPath) { + t.Fatalf("second pi rules pruned = %v, want %q", secondResult.Pruned, managedAgentsPath) + } + if _, err := os.Stat(managedAgentsPath); !os.IsNotExist(err) { + t.Fatalf("expected managed AGENTS.md to be pruned, got err=%v", err) + } +} + +func TestSync_PrunesNestedPiAgentsAfterSourceDeletion(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + + projectRoot := t.TempDir() + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "pi/nested/AGENTS.md", + Content: []byte("# Nested Pi Agents\n"), + }); err != nil { + t.Fatalf("put managed nested pi agents: %v", err) + } + + req := SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{{ + Name: "pi", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".pi", "skills")}, + }}, + } + + first := Sync(req) + if firstResult := findSyncResult(t, first, "pi", "rules"); firstResult.Err != nil { + t.Fatalf("first pi rules sync error = %v", firstResult.Err) + } + + nestedAgentsPath := filepath.Join(projectRoot, "nested", "AGENTS.md") + if _, err := os.Stat(nestedAgentsPath); err != nil { + t.Fatalf("expected nested managed AGENTS.md after first sync: %v", err) + } + + if err := ruleStore.Delete("pi/nested/AGENTS.md"); err != nil { + t.Fatalf("delete managed nested pi agents: %v", err) + } + + second := Sync(req) + secondResult := findSyncResult(t, second, "pi", "rules") + if secondResult.Err != nil { + t.Fatalf("second pi rules sync error = %v", secondResult.Err) + } + if !containsAll(secondResult.Pruned, nestedAgentsPath) { + t.Fatalf("second pi rules pruned = %v, want %q", secondResult.Pruned, nestedAgentsPath) + } + if _, err := os.Stat(nestedAgentsPath); !os.IsNotExist(err) { + t.Fatalf("expected nested managed AGENTS.md to be pruned, got err=%v", err) + } +} + +func TestSync_KeepsNestedPiAgentsOnUnchangedSync(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + + projectRoot := t.TempDir() + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "pi/nested/AGENTS.md", + Content: []byte("# Nested Pi Agents\n"), + }); err != nil { + t.Fatalf("put managed nested pi agents: %v", err) + } + + req := SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{{ + Name: "pi", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".pi", "skills")}, + }}, + } + + first := Sync(req) + if firstResult := findSyncResult(t, first, "pi", "rules"); firstResult.Err != nil { + t.Fatalf("first pi rules sync error = %v", firstResult.Err) + } + + nestedAgentsPath := filepath.Join(projectRoot, "nested", "AGENTS.md") + if _, err := os.Stat(nestedAgentsPath); err != nil { + t.Fatalf("expected nested managed AGENTS.md after first sync: %v", err) + } + + second := Sync(req) + secondResult := findSyncResult(t, second, "pi", "rules") + if secondResult.Err != nil { + t.Fatalf("second pi rules sync error = %v", secondResult.Err) + } + for _, pruned := range secondResult.Pruned { + if pruned == nestedAgentsPath { + t.Fatalf("second pi rules pruned = %v, want nested AGENTS to remain managed", secondResult.Pruned) + } + } + if _, err := os.Stat(nestedAgentsPath); err != nil { + t.Fatalf("expected nested managed AGENTS.md to remain after unchanged sync: %v", err) + } +} + +func TestSync_ProjectModePiSyncDoesNotPruneManualProjectRootAgents(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + + projectRoot := t.TempDir() + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "pi/AGENTS.md", + Content: []byte("# Managed Agents\n"), + }); err != nil { + t.Fatalf("put managed pi agents: %v", err) + } + + first := Sync(SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{{ + Name: "pi", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".pi", "skills")}, + }}, + }) + + firstResult := findSyncResult(t, first, "pi", "rules") + if firstResult.Err != nil { + t.Fatalf("first pi rules sync error = %v", firstResult.Err) + } + manualAgentsPath := filepath.Join(projectRoot, "AGENTS.md") + if err := os.WriteFile(manualAgentsPath, []byte("# Manual Agents\n"), 0o644); err != nil { + t.Fatalf("rewrite manual AGENTS.md: %v", err) + } + + if err := ruleStore.Delete("pi/AGENTS.md"); err != nil { + t.Fatalf("delete managed pi agents: %v", err) + } + + second := Sync(SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{{ + Name: "pi", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".pi", "skills")}, + }}, + }) + + result := findSyncResult(t, second, "pi", "rules") + if result.Err != nil { + t.Fatalf("pi rules sync error = %v", result.Err) + } + if containsAll(result.Pruned, manualAgentsPath) { + t.Fatalf("pi rules pruned = %v, manual AGENTS.md must not be pruned", result.Pruned) + } + if got := readFile(t, manualAgentsPath); got != "# Manual Agents\n" { + t.Fatalf("project AGENTS.md = %q, want manual content preserved", got) + } +} + +func TestSync_ProjectModePiSyncDisownsManualProjectRootAgentsAfterPreserve(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + + projectRoot := t.TempDir() + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "pi/AGENTS.md", + Content: []byte("# Managed Agents\n"), + }); err != nil { + t.Fatalf("put managed pi agents: %v", err) + } + + req := SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{{ + Name: "pi", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".pi", "skills")}, + }}, + } + + first := Sync(req) + if firstResult := findSyncResult(t, first, "pi", "rules"); firstResult.Err != nil { + t.Fatalf("first pi rules sync error = %v", firstResult.Err) + } + + agentsPath := filepath.Join(projectRoot, "AGENTS.md") + if err := os.WriteFile(agentsPath, []byte("# Manual Agents\n"), 0o644); err != nil { + t.Fatalf("write manual AGENTS.md: %v", err) + } + if err := ruleStore.Delete("pi/AGENTS.md"); err != nil { + t.Fatalf("delete managed pi agents: %v", err) + } + + second := Sync(req) + secondResult := findSyncResult(t, second, "pi", "rules") + if secondResult.Err != nil { + t.Fatalf("second pi rules sync error = %v", secondResult.Err) + } + if containsAll(secondResult.Pruned, agentsPath) { + t.Fatalf("second pi rules pruned = %v, manual AGENTS.md must not be pruned", secondResult.Pruned) + } + + if err := os.WriteFile(agentsPath, []byte("# Managed Agents\n"), 0o644); err != nil { + t.Fatalf("rewrite AGENTS.md with prior managed content: %v", err) + } + + third := Sync(req) + thirdResult := findSyncResult(t, third, "pi", "rules") + if thirdResult.Err != nil { + t.Fatalf("third pi rules sync error = %v", thirdResult.Err) + } + if containsAll(thirdResult.Pruned, agentsPath) { + t.Fatalf("third pi rules pruned = %v, stale managed ownership must be cleared after preserve", thirdResult.Pruned) + } + if got := readFile(t, agentsPath); got != "# Managed Agents\n" { + t.Fatalf("project AGENTS.md = %q, want preserved manual content", got) + } +} + +func TestSync_ProjectModePiSyncDisownsProjectRootAgentsAfterPrune(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + + projectRoot := t.TempDir() + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "pi/AGENTS.md", + Content: []byte("# Pi Agents\n"), + }); err != nil { + t.Fatalf("put managed pi agents: %v", err) + } + + req := SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{{ + Name: "pi", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".pi", "skills")}, + }}, + } + + first := Sync(req) + if firstResult := findSyncResult(t, first, "pi", "rules"); firstResult.Err != nil { + t.Fatalf("first pi rules sync error = %v", firstResult.Err) + } + + if err := ruleStore.Delete("pi/AGENTS.md"); err != nil { + t.Fatalf("delete managed pi agents: %v", err) + } + + second := Sync(req) + secondResult := findSyncResult(t, second, "pi", "rules") + agentsPath := filepath.Join(projectRoot, "AGENTS.md") + if secondResult.Err != nil { + t.Fatalf("second pi rules sync error = %v", secondResult.Err) + } + if !containsAll(secondResult.Pruned, agentsPath) { + t.Fatalf("second pi rules pruned = %v, want %q", secondResult.Pruned, agentsPath) + } + + if err := os.WriteFile(agentsPath, []byte("# Pi Agents\n"), 0o644); err != nil { + t.Fatalf("recreate AGENTS.md with prior managed content: %v", err) + } + + third := Sync(req) + thirdResult := findSyncResult(t, third, "pi", "rules") + if thirdResult.Err != nil { + t.Fatalf("third pi rules sync error = %v", thirdResult.Err) + } + if containsAll(thirdResult.Pruned, agentsPath) { + t.Fatalf("third pi rules pruned = %v, stale managed ownership must be cleared after prune", thirdResult.Pruned) + } + if got := readFile(t, agentsPath); got != "# Pi Agents\n" { + t.Fatalf("project AGENTS.md = %q, want recreated manual content preserved", got) + } +} + +func TestSync_PreservesProjectRootAgentsWhenAnotherCurrentTargetStillProducesIt(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + + projectRoot := t.TempDir() + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "codex/AGENTS.md", + Content: []byte("# Codex Agents\n"), + }); err != nil { + t.Fatalf("put managed codex agents: %v", err) + } + if _, err := ruleStore.Put(managedrules.Save{ + ID: "pi/AGENTS.md", + Content: []byte("# Pi Agents\n"), + }); err != nil { + t.Fatalf("put managed pi agents: %v", err) + } + + first := Sync(SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{ + { + Name: "pi", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".pi", "skills")}, + }, + }, + }) + + if piFirst := findSyncResult(t, first, "pi", "rules"); piFirst.Err != nil { + t.Fatalf("first pi rules sync error = %v", piFirst.Err) + } + if got := readFile(t, filepath.Join(projectRoot, "AGENTS.md")); !strings.Contains(got, "# Pi Agents") { + t.Fatalf("managed AGENTS.md = %q, want pi content", got) + } + + if err := ruleStore.Delete("pi/AGENTS.md"); err != nil { + t.Fatalf("delete managed pi agents: %v", err) + } + + second := Sync(SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{ + { + Name: "pi", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".pi", "skills")}, + }, + { + Name: "codex", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".codex", "skills")}, + }, + }, + }) + + piResult := findSyncResult(t, second, "pi", "rules") + if piResult.Err != nil { + t.Fatalf("second pi rules sync error = %v", piResult.Err) + } + if containsAll(piResult.Pruned, filepath.Join(projectRoot, "AGENTS.md")) { + t.Fatalf("pi rules pruned = %v, shared AGENTS.md must not be pruned while codex still produces it", piResult.Pruned) + } + if codexResult := findSyncResult(t, second, "codex", "rules"); codexResult.Err != nil { + t.Fatalf("second codex rules sync error = %v", codexResult.Err) + } + if got := readFile(t, filepath.Join(projectRoot, "AGENTS.md")); !strings.Contains(got, "# Codex Agents") { + t.Fatalf("shared AGENTS.md = %q, want codex content preserved", got) + } +} + +func TestSync_RejectsProjectRootAgentsConflictBetweenCodexAndPi(t *testing.T) { + projectRoot := t.TempDir() + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "codex/AGENTS.md", + Content: []byte("# Codex Agents\n"), + }); err != nil { + t.Fatalf("put managed codex rule: %v", err) + } + if _, err := ruleStore.Put(managedrules.Save{ + ID: "pi/AGENTS.md", + Content: []byte("# Pi Agents\n"), + }); err != nil { + t.Fatalf("put managed pi rule: %v", err) + } + + results := Sync(SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{ + { + Name: "codex", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".codex", "skills")}, + }, + { + Name: "pi", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".pi", "skills")}, + }, + }, + }) + + codexResult := findSyncResult(t, results, "codex", "rules") + if codexResult.Err == nil { + t.Fatal("codex rules sync error = nil, want shared AGENTS conflict") + } + if !strings.Contains(codexResult.Err.Error(), "conflict") || !strings.Contains(codexResult.Err.Error(), "AGENTS.md") { + t.Fatalf("codex rules sync error = %v, want AGENTS conflict", codexResult.Err) + } + + piResult := findSyncResult(t, results, "pi", "rules") + if piResult.Err == nil { + t.Fatal("pi rules sync error = nil, want shared AGENTS conflict") + } + if !strings.Contains(piResult.Err.Error(), "conflict") || !strings.Contains(piResult.Err.Error(), "AGENTS.md") { + t.Fatalf("pi rules sync error = %v, want AGENTS conflict", piResult.Err) + } + + if _, err := os.Stat(filepath.Join(projectRoot, "AGENTS.md")); !os.IsNotExist(err) { + t.Fatalf("expected no shared AGENTS.md to be written, got err=%v", err) + } +} + +func TestSync_RejectsProjectRootAgentsConflictForSingleTargetSync(t *testing.T) { + projectRoot := t.TempDir() + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "codex/AGENTS.md", + Content: []byte("# Codex Agents\n"), + }); err != nil { + t.Fatalf("put managed codex rule: %v", err) + } + if _, err := ruleStore.Put(managedrules.Save{ + ID: "pi/AGENTS.md", + Content: []byte("# Pi Agents\n"), + }); err != nil { + t.Fatalf("put managed pi rule: %v", err) + } + + results := Sync(SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{{ + Name: "pi", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".pi", "skills")}, + }}, + AllTargets: []TargetSyncSpec{ + { + Name: "codex", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".codex", "skills")}, + }, + { + Name: "pi", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".pi", "skills")}, + }, + }, + }) + + piResult := findSyncResult(t, results, "pi", "rules") + if piResult.Err == nil { + t.Fatal("pi rules sync error = nil, want shared AGENTS conflict") + } + if !strings.Contains(piResult.Err.Error(), "conflict") || !strings.Contains(piResult.Err.Error(), "AGENTS.md") { + t.Fatalf("pi rules sync error = %v, want AGENTS conflict", piResult.Err) + } + + if _, err := os.Stat(filepath.Join(projectRoot, "AGENTS.md")); !os.IsNotExist(err) { + t.Fatalf("expected no shared AGENTS.md to be written, got err=%v", err) + } +} + +func TestSync_RejectsProjectRootAgentsConflictForSingleTargetSyncWithNonCanonicalCodexFamilyTarget(t *testing.T) { + projectRoot := t.TempDir() + customCodexTarget := TargetSyncSpec{ + Name: "my-codex", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".agents", "skills")}, + } + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "codex/AGENTS.md", + Content: []byte("# Codex Agents\n"), + Targets: []string{"my-codex"}, + }); err != nil { + t.Fatalf("put managed codex rule: %v", err) + } + if _, err := ruleStore.Put(managedrules.Save{ + ID: "pi/AGENTS.md", + Content: []byte("# Pi Agents\n"), + }); err != nil { + t.Fatalf("put managed pi rule: %v", err) + } + + results := Sync(SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{{ + Name: "pi", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".pi", "skills")}, + }}, + AllTargets: []TargetSyncSpec{ + { + Name: "pi", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".pi", "skills")}, + }, + customCodexTarget, + }, + }) + + piResult := findSyncResult(t, results, "pi", "rules") + if piResult.Err == nil { + t.Fatal("pi rules sync error = nil, want shared AGENTS conflict") + } + if !strings.Contains(piResult.Err.Error(), "conflict") || !strings.Contains(piResult.Err.Error(), "AGENTS.md") || !strings.Contains(piResult.Err.Error(), "my-codex") { + t.Fatalf("pi rules sync error = %v, want AGENTS conflict mentioning my-codex target", piResult.Err) + } + + if _, err := os.Stat(filepath.Join(projectRoot, "AGENTS.md")); !os.IsNotExist(err) { + t.Fatalf("expected no shared AGENTS.md to be written, got err=%v", err) + } +} + +func TestSync_RejectsSharedOutputConflictBetweenSameFamilyTargets(t *testing.T) { + projectRoot := t.TempDir() + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "codex/intro.md", + Content: []byte("# Codex Intro\n"), + Targets: []string{"codex"}, + SourceType: "local", + }); err != nil { + t.Fatalf("put codex rule: %v", err) + } + if _, err := ruleStore.Put(managedrules.Save{ + ID: "codex/alt.md", + Content: []byte("# Universal Intro\n"), + Targets: []string{"universal"}, + SourceType: "local", + }); err != nil { + t.Fatalf("put universal rule: %v", err) + } + + results := Sync(SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{ + { + Name: "codex", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".codex", "skills")}, + }, + { + Name: "universal", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".agents", "skills")}, + }, + }, + }) + + codexResult := findSyncResult(t, results, "codex", "rules") + if codexResult.Err == nil { + t.Fatal("codex rules sync error = nil, want shared AGENTS conflict") + } + if !strings.Contains(codexResult.Err.Error(), "conflict") || !strings.Contains(codexResult.Err.Error(), "AGENTS.md") { + t.Fatalf("codex rules sync error = %v, want AGENTS conflict", codexResult.Err) + } + + universalResult := findSyncResult(t, results, "universal", "rules") + if universalResult.Err == nil { + t.Fatal("universal rules sync error = nil, want shared AGENTS conflict") + } + if !strings.Contains(universalResult.Err.Error(), "conflict") || !strings.Contains(universalResult.Err.Error(), "AGENTS.md") { + t.Fatalf("universal rules sync error = %v, want AGENTS conflict", universalResult.Err) + } + + if _, err := os.Stat(filepath.Join(projectRoot, "AGENTS.md")); !os.IsNotExist(err) { + t.Fatalf("expected no shared AGENTS.md to be written, got err=%v", err) + } +} + +func TestSync_AllowsSharedOutputBetweenSameFamilyTargetsWhenContentsMatch(t *testing.T) { + projectRoot := t.TempDir() + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "codex/shared.md", + Content: []byte("# Shared Intro\n"), + SourceType: "local", + }); err != nil { + t.Fatalf("put codex rule: %v", err) + } + + results := Sync(SyncRequest{ + ProjectRoot: projectRoot, + DryRun: false, + Resources: ResourceSet{Rules: true}, + Targets: []TargetSyncSpec{ + { + Name: "codex", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".codex", "skills")}, + }, + { + Name: "universal", + Target: config.TargetConfig{Path: filepath.Join(projectRoot, ".agents", "skills")}, + }, + }, + }) + + codexResult := findSyncResult(t, results, "codex", "rules") + if codexResult.Err != nil { + t.Fatalf("codex rules sync error = %v, want nil", codexResult.Err) + } + + universalResult := findSyncResult(t, results, "universal", "rules") + if universalResult.Err != nil { + t.Fatalf("universal rules sync error = %v, want nil", universalResult.Err) + } + + if got := readFile(t, filepath.Join(projectRoot, "AGENTS.md")); !strings.Contains(got, "# Shared Intro") { + t.Fatalf("shared AGENTS.md = %q, want shared content", got) + } +} + +func ensureClaudeTargetFiles(t *testing.T, projectRoot string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(projectRoot, ".claude"), 0o755); err != nil { + t.Fatalf("mkdir .claude: %v", err) + } + if err := os.WriteFile(filepath.Join(projectRoot, ".claude", "settings.json"), []byte(`{"profiles":{"default":{"model":"gpt-5"}}}`), 0o644); err != nil { + t.Fatalf("write settings.json: %v", err) + } +} + +func findSyncResult(t *testing.T, results []SyncResult, target, resource string) SyncResult { + t.Helper() + for _, result := range results { + if result.Target == target && result.Resource == resource { + return result + } + } + t.Fatalf("missing sync result for target=%q resource=%q; results=%#v", target, resource, results) + return SyncResult{} +} + +func containsAll(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(data) +} diff --git a/internal/resources/managed/types.go b/internal/resources/managed/types.go new file mode 100644 index 00000000..9d0e38f7 --- /dev/null +++ b/internal/resources/managed/types.go @@ -0,0 +1,47 @@ +package managed + +import "skillshare/internal/config" + +// ResourceSet selects which managed resource kinds are synced. +type ResourceSet struct { + Rules bool + Hooks bool +} + +// TargetSyncSpec describes one target participating in a managed sync run. +type TargetSyncSpec struct { + Name string + Target config.TargetConfig +} + +// SyncRequest configures one managed sync run. +type SyncRequest struct { + ProjectRoot string + DryRun bool + Resources ResourceSet + Targets []TargetSyncSpec + AllTargets []TargetSyncSpec +} + +// SyncResult reports one target/resource sync outcome. +type SyncResult struct { + Target string + Resource string + Updated []string + Skipped []string + Pruned []string + Err error +} + +// CollectPreviewResult reports which discovered items would be collected. +type CollectPreviewResult struct { + Pulled []string + Skipped []string +} + +// CollectResult reports which discovered items were created or overwritten. +type CollectResult struct { + Created []string + Overwritten []string + Skipped []string +} diff --git a/internal/resources/managed/validate.go b/internal/resources/managed/validate.go new file mode 100644 index 00000000..f82828de --- /dev/null +++ b/internal/resources/managed/validate.go @@ -0,0 +1,51 @@ +package managed + +import ( + "fmt" + "strings" +) + +type RuleInput struct { + Tool string + RelativePath string + Content []byte +} + +type HookHandlerInput struct { + Type string + Name string + Description string + Command string + URL string + Prompt string + Timeout string + TimeoutSeconds *int + StatusMessage string +} + +type HookInput struct { + Tool string + Event string + Matcher string + Handlers []HookHandlerInput +} + +func validateManagedFamily(kind ResourceKind, targetName, targetPath string) error { + if strings.TrimSpace(targetName) == "" && strings.TrimSpace(targetPath) == "" { + return nil + } + if _, ok := ResolveManagedFamily(kind, targetName, targetPath); ok { + return nil + } + return fmt.Errorf("tool %q does not support managed %s", strings.TrimSpace(targetName), kind) +} + +// ValidateManagedRuleSave checks whether a managed rule mutation targets a supported family. +func ValidateManagedRuleSave(in RuleInput) error { + return validateManagedFamily(ResourceKindRules, in.Tool, "") +} + +// ValidateManagedHookSave checks whether a managed hook mutation targets a supported family. +func ValidateManagedHookSave(in HookInput) error { + return validateManagedFamily(ResourceKindHooks, in.Tool, "") +} diff --git a/internal/resources/managed/validate_test.go b/internal/resources/managed/validate_test.go new file mode 100644 index 00000000..27cf64a6 --- /dev/null +++ b/internal/resources/managed/validate_test.go @@ -0,0 +1,32 @@ +package managed + +import "testing" + +func TestValidateManagedRuleSave_AllowsPiRules(t *testing.T) { + err := ValidateManagedRuleSave(RuleInput{ + Tool: "pi", + RelativePath: "pi/SYSTEM.md", + Content: []byte("# Pi\n"), + }) + if err != nil { + t.Fatalf("expected pi rules to validate, got %v", err) + } +} + +func TestValidateManagedHookSave_RejectsPiHooks(t *testing.T) { + err := ValidateManagedHookSave(HookInput{ + Tool: "pi", + Event: "PreToolUse", + Matcher: "Read", + Handlers: []HookHandlerInput{{ + Type: "command", + Command: "./bin/check", + }}, + }) + if err == nil { + t.Fatal("expected pi hooks validation error") + } + if got := err.Error(); got != `tool "pi" does not support managed hooks` { + t.Fatalf("validation error = %q, want managed family support error", got) + } +} diff --git a/internal/resources/rules/collect.go b/internal/resources/rules/collect.go new file mode 100644 index 00000000..c8eaa213 --- /dev/null +++ b/internal/resources/rules/collect.go @@ -0,0 +1,339 @@ +package rules + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "skillshare/internal/inspect" + managedpi "skillshare/internal/resources/managed/pi" +) + +type Strategy string + +const ( + StrategySkip Strategy = "skip" + StrategyOverwrite Strategy = "overwrite" + StrategyDuplicate Strategy = "duplicate" +) + +type CollectOptions struct { + Strategy Strategy +} + +type CollectResult struct { + Created []string + Overwritten []string + Skipped []string +} + +type collectAppliedWrite struct { + id string + hadPrior bool + priorRule Record +} + +type collectPlan struct { + result CollectResult + writes []Save +} + +func invalidCollectf(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrInvalidCollect, fmt.Sprintf(format, args...)) +} + +// PreviewCollect validates discovered rules and reports what Collect would do. +func PreviewCollect(projectRoot string, discovered []inspect.RuleItem, opts CollectOptions) (CollectResult, error) { + strategy, err := normalizeStrategy(opts.Strategy) + if err != nil { + return CollectResult{}, err + } + + store := NewStore(projectRoot) + existing, err := store.List() + if err != nil { + return CollectResult{}, err + } + + plan, err := planCollect(existing, discovered, strategy) + if err != nil { + return CollectResult{}, err + } + return plan.result, nil +} + +// Collect imports discovered rule files into managed rules storage. +func Collect(projectRoot string, discovered []inspect.RuleItem, opts CollectOptions) (CollectResult, error) { + strategy, err := normalizeStrategy(opts.Strategy) + if err != nil { + return CollectResult{}, err + } + + store := NewStore(projectRoot) + existing, err := store.List() + if err != nil { + return CollectResult{}, err + } + + plan, err := planCollect(existing, discovered, strategy) + if err != nil { + return CollectResult{}, err + } + + existingByID := make(map[string]Record, len(existing)+len(plan.writes)) + for _, record := range existing { + existingByID[record.ID] = record + } + + applied := make([]collectAppliedWrite, 0, len(plan.writes)) + for _, write := range plan.writes { + prior, hadPrior := existingByID[write.ID] + applied = append(applied, collectAppliedWrite{ + id: write.ID, + hadPrior: hadPrior, + priorRule: prior, + }) + + record, err := store.Put(write) + if err != nil { + rollbackErr := rollbackAppliedWrites(store, applied[:len(applied)-1]) + if rollbackErr != nil { + return CollectResult{}, fmt.Errorf("apply collected rules: %w; rollback failed: %v", err, rollbackErr) + } + return CollectResult{}, err + } + existingByID[write.ID] = record + } + + return plan.result, nil +} + +func planCollect(existing []Record, discovered []inspect.RuleItem, strategy Strategy) (collectPlan, error) { + if err := rejectCanonicalManagedIDCollisions(discovered); err != nil { + return collectPlan{}, err + } + + takenIDs := make(map[string]bool, len(existing)+len(discovered)) + for _, record := range existing { + takenIDs[record.ID] = true + } + + plan := collectPlan{ + result: CollectResult{}, + writes: make([]Save, 0, len(discovered)), + } + + for _, item := range discovered { + if !item.Collectible { + reason := strings.TrimSpace(item.CollectReason) + if reason == "" { + reason = "rule is not collectible" + } + return collectPlan{}, invalidCollectf("cannot collect %s: %s", item.Path, reason) + } + + id, err := managedIDForDiscoveredRule(item) + if err != nil { + return collectPlan{}, err + } + exists := takenIDs[id] + + switch { + case !exists: + plan.writes = append(plan.writes, Save{ + ID: id, + Content: []byte(item.Content), + SourceType: "local", + }) + takenIDs[id] = true + plan.result.Created = append(plan.result.Created, id) + case strategy == StrategySkip: + plan.result.Skipped = append(plan.result.Skipped, id) + case strategy == StrategyOverwrite: + prior := managedRuleByID(existing, id) + plan.writes = append(plan.writes, Save{ + ID: id, + Content: []byte(item.Content), + Targets: append([]string(nil), prior.Targets...), + SourceType: prior.SourceType, + Disabled: prior.Disabled, + }) + takenIDs[id] = true + plan.result.Overwritten = append(plan.result.Overwritten, id) + case strategy == StrategyDuplicate: + if managedpi.IsManagedRuleID(id) { + return collectPlan{}, invalidCollectf("cannot collect %s: managed pi rules use fixed instruction surfaces", item.Path) + } + duplicateID := nextDuplicateIDFromTaken(takenIDs, id) + plan.writes = append(plan.writes, Save{ + ID: duplicateID, + Content: []byte(item.Content), + SourceType: "local", + }) + takenIDs[duplicateID] = true + plan.result.Created = append(plan.result.Created, duplicateID) + } + } + + return plan, nil +} + +func rejectCanonicalManagedIDCollisions(discovered []inspect.RuleItem) error { + byID := make(map[string]inspect.RuleItem, len(discovered)) + for _, item := range discovered { + id, err := managedIDForDiscoveredRule(item) + if err != nil { + return err + } + if prior, ok := byID[id]; ok && prior.Path != item.Path { + return invalidCollectf("cannot collect %s and %s: canonical managed id %q collides", prior.Path, item.Path, id) + } + byID[id] = item + } + return nil +} + +func rollbackAppliedWrites(store *Store, applied []collectAppliedWrite) error { + var firstErr error + for i := len(applied) - 1; i >= 0; i-- { + entry := applied[i] + if entry.hadPrior { + if _, err := store.Put(Save{ + ID: entry.priorRule.ID, + Content: entry.priorRule.Content, + Targets: append([]string(nil), entry.priorRule.Targets...), + SourceType: entry.priorRule.SourceType, + Disabled: entry.priorRule.Disabled, + }); err != nil && firstErr == nil { + firstErr = err + } + continue + } + if err := store.Delete(entry.id); err != nil && !os.IsNotExist(err) && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func managedRuleByID(existing []Record, id string) Record { + for _, record := range existing { + if record.ID == id { + return record + } + } + return Record{} +} + +func normalizeStrategy(strategy Strategy) (Strategy, error) { + switch strings.TrimSpace(string(strategy)) { + case "": + return StrategySkip, nil + case string(StrategySkip): + return StrategySkip, nil + case string(StrategyOverwrite): + return StrategyOverwrite, nil + case string(StrategyDuplicate): + return StrategyDuplicate, nil + default: + return "", invalidCollectf("invalid collect strategy %q", strategy) + } +} + +func nextDuplicateIDFromTaken(taken map[string]bool, id string) string { + ext := path.Ext(id) + base := strings.TrimSuffix(path.Base(id), ext) + dir := path.Dir(id) + if dir == "." { + dir = "" + } + + candidateFor := func(suffix string) string { + name := base + suffix + ext + if dir == "" { + return name + } + return path.Join(dir, name) + } + + first := candidateFor("-copy") + if !taken[first] { + return first + } + + for i := 2; ; i++ { + candidate := candidateFor(fmt.Sprintf("-copy-%d", i)) + if !taken[candidate] { + return candidate + } + } +} + +// ManagedIDForDiscoveredRule returns the canonical managed rule ID for one discovered rule. +func ManagedIDForDiscoveredRule(item inspect.RuleItem) (string, error) { + return managedIDForDiscoveredRule(item) +} + +func managedIDForDiscoveredRule(item inspect.RuleItem) (string, error) { + tool := strings.ToLower(strings.TrimSpace(item.SourceTool)) + if tool == "" { + return "", invalidCollectf("cannot collect %s: missing source tool", item.Path) + } + + p := filepath.ToSlash(strings.TrimSpace(item.Path)) + base := path.Base(p) + + switch tool { + case "claude": + if rel, ok := relativeAfterSegment(p, "/.claude/rules/"); ok { + return "claude/" + rel, nil + } + if strings.EqualFold(base, "CLAUDE.md") { + return "claude/CLAUDE.md", nil + } + case "codex": + if rel, ok := relativeAfterSegment(p, "/.codex/rules/"); ok { + return "codex/" + rel, nil + } + if strings.EqualFold(base, "AGENTS.md") { + return "codex/AGENTS.md", nil + } + case "gemini": + if rel, ok := relativeAfterSegment(p, "/.gemini/rules/"); ok { + return "gemini/" + rel, nil + } + if strings.EqualFold(base, "GEMINI.md") { + return "gemini/GEMINI.md", nil + } + case "pi": + if id, ok := managedpi.ManagedRuleIDForDiscoveredPath(p); ok { + return id, nil + } + return "", invalidCollectf("cannot collect %s: unsupported pi rule path", item.Path) + } + + if base == "." || base == "/" || strings.TrimSpace(base) == "" { + return "", invalidCollectf("cannot collect %s: invalid rule filename", item.Path) + } + return tool + "/" + base, nil +} + +func relativeAfterSegment(p string, segment string) (string, bool) { + lowerPath := strings.ToLower(p) + lowerSegment := strings.ToLower(segment) + idx := strings.Index(lowerPath, lowerSegment) + if idx < 0 { + return "", false + } + rel := p[idx+len(segment):] + if strings.TrimSpace(rel) == "" { + return "", false + } + rel = path.Clean(rel) + if rel == "." || strings.HasPrefix(rel, "../") || strings.HasPrefix(rel, "/") { + return "", false + } + return rel, true +} diff --git a/internal/resources/rules/collect_test.go b/internal/resources/rules/collect_test.go new file mode 100644 index 00000000..cc40e587 --- /dev/null +++ b/internal/resources/rules/collect_test.go @@ -0,0 +1,463 @@ +package rules + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "skillshare/internal/inspect" +) + +func TestCollectRules_OverwriteAndDuplicate(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + _, err := store.Put(Save{ + ID: "claude/backend.md", + Content: []byte("# Existing\n"), + Targets: []string{"gemini"}, + SourceType: "tracked", + Disabled: true, + }) + if err != nil { + t.Fatalf("seed Put() error = %v", err) + } + + discovered := []inspect.RuleItem{ + { + Name: "backend.md", + SourceTool: "claude", + Scope: inspect.ScopeProject, + Path: "/tmp/project/.claude/rules/backend.md", + Content: "# Backend\n", + Collectible: true, + }, + } + + result, err := Collect(projectRoot, discovered, CollectOptions{Strategy: StrategyDuplicate}) + if err != nil { + t.Fatalf("Collect(duplicate) error = %v", err) + } + if len(result.Created) != 1 || result.Created[0] != "claude/backend-copy.md" { + t.Fatalf("Collect(duplicate) Created = %v, want [claude/backend-copy.md]", result.Created) + } + + original, err := store.Get("claude/backend.md") + if err != nil { + t.Fatalf("Get(original) error = %v", err) + } + if string(original.Content) != "# Existing\n" { + t.Fatalf("original content = %q, want %q", string(original.Content), "# Existing\n") + } + + copyRule, err := store.Get("claude/backend-copy.md") + if err != nil { + t.Fatalf("Get(copy) error = %v", err) + } + if string(copyRule.Content) != "# Backend\n" { + t.Fatalf("copy content = %q, want %q", string(copyRule.Content), "# Backend\n") + } + if copyRule.Targets != nil { + t.Fatalf("copy targets = %v, want nil", copyRule.Targets) + } + if copyRule.SourceType != "local" { + t.Fatalf("copy sourceType = %q, want %q", copyRule.SourceType, "local") + } + if copyRule.Disabled { + t.Fatalf("copy disabled = %v, want false", copyRule.Disabled) + } + + discovered[0].Content = "# Overwritten\n" + result, err = Collect(projectRoot, discovered, CollectOptions{Strategy: StrategyOverwrite}) + if err != nil { + t.Fatalf("Collect(overwrite) error = %v", err) + } + if len(result.Overwritten) != 1 || result.Overwritten[0] != "claude/backend.md" { + t.Fatalf("Collect(overwrite) Overwritten = %v, want [claude/backend.md]", result.Overwritten) + } + + overwritten, err := store.Get("claude/backend.md") + if err != nil { + t.Fatalf("Get(overwritten) error = %v", err) + } + if string(overwritten.Content) != "# Overwritten\n" { + t.Fatalf("overwritten content = %q, want %q", string(overwritten.Content), "# Overwritten\n") + } + if len(overwritten.Targets) != 1 || overwritten.Targets[0] != "gemini" { + t.Fatalf("overwritten targets = %v, want [gemini]", overwritten.Targets) + } + if overwritten.SourceType != "tracked" { + t.Fatalf("overwritten sourceType = %q, want %q", overwritten.SourceType, "tracked") + } + if !overwritten.Disabled { + t.Fatalf("overwritten disabled = %v, want true", overwritten.Disabled) + } + + discovered[0].Content = "# ShouldSkip\n" + result, err = Collect(projectRoot, discovered, CollectOptions{Strategy: StrategySkip}) + if err != nil { + t.Fatalf("Collect(skip) error = %v", err) + } + if len(result.Skipped) != 1 || result.Skipped[0] != "claude/backend.md" { + t.Fatalf("Collect(skip) Skipped = %v, want [claude/backend.md]", result.Skipped) + } + + skipped, err := store.Get("claude/backend.md") + if err != nil { + t.Fatalf("Get(skipped) error = %v", err) + } + if string(skipped.Content) != "# Overwritten\n" { + t.Fatalf("skipped content = %q, want %q", string(skipped.Content), "# Overwritten\n") + } +} + +func TestCollectRules_DoesNotPartiallyWriteOnLaterFailure(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + discovered := []inspect.RuleItem{ + { + Name: "backend.md", + SourceTool: "claude", + Scope: inspect.ScopeProject, + Path: "/tmp/project/.claude/rules/backend.md", + Content: "# Backend\n", + Collectible: true, + }, + { + Name: "blocked.md", + SourceTool: "claude", + Scope: inspect.ScopeProject, + Path: "/tmp/project/.claude/rules/blocked.md", + Content: "# Blocked\n", + Collectible: false, + CollectReason: "blocked by policy", + }, + } + + _, err := Collect(projectRoot, discovered, CollectOptions{Strategy: StrategyOverwrite}) + if err == nil { + t.Fatal("Collect() error = nil, want non-collectible error") + } + + _, err = store.Get("claude/backend.md") + if !os.IsNotExist(err) { + t.Fatalf("Get(claude/backend.md) error = %v, want not-exist", err) + } + + all, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(all) != 0 { + t.Fatalf("List() len = %d, want 0", len(all)) + } +} + +func TestCollectRules_DuplicateRejectsFixedPiSurface(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + _, err := store.Put(Save{ + ID: "pi/SYSTEM.md", + Content: []byte("# Existing Pi System\n"), + SourceType: "tracked", + }) + if err != nil { + t.Fatalf("seed Put() error = %v", err) + } + + discovered := []inspect.RuleItem{ + { + Name: "SYSTEM.md", + SourceTool: "pi", + Scope: inspect.ScopeProject, + Path: "/tmp/project/.pi/SYSTEM.md", + Content: "# Pi System\n", + Collectible: true, + }, + } + + _, err = Collect(projectRoot, discovered, CollectOptions{Strategy: StrategyDuplicate}) + if err == nil { + t.Fatal("Collect() error = nil, want fixed-surface duplicate error") + } + if !errors.Is(err, ErrInvalidCollect) { + t.Fatalf("Collect() error = %v, want ErrInvalidCollect", err) + } + if !strings.Contains(err.Error(), "fixed instruction surface") { + t.Fatalf("Collect() error = %v, want fixed-surface message", err) + } + + record, err := store.Get("pi/SYSTEM.md") + if err != nil { + t.Fatalf("Get(pi/SYSTEM.md) error = %v", err) + } + if string(record.Content) != "# Existing Pi System\n" { + t.Fatalf("pi/SYSTEM.md content = %q, want original content", string(record.Content)) + } + + all, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(all) != 1 { + t.Fatalf("List() len = %d, want 1", len(all)) + } +} + +func TestCollectRules_RejectsCanonicalManagedIDCollisions(t *testing.T) { + projectRoot := t.TempDir() + + discovered := []inspect.RuleItem{ + { + Name: "CLAUDE.md", + SourceTool: "claude", + Scope: inspect.ScopeProject, + Path: "/tmp/project/CLAUDE.md", + Content: "# Root\n", + Collectible: true, + }, + { + Name: "CLAUDE.md", + SourceTool: "claude", + Scope: inspect.ScopeProject, + Path: "/tmp/project/.claude/CLAUDE.md", + Content: "# Nested\n", + Collectible: true, + }, + } + + _, err := Collect(projectRoot, discovered, CollectOptions{Strategy: StrategyOverwrite}) + if err == nil { + t.Fatal("expected collect collision error") + } + if !errors.Is(err, ErrInvalidCollect) { + t.Fatalf("Collect() error = %v, want ErrInvalidCollect", err) + } + if !strings.Contains(err.Error(), "canonical managed id") { + t.Fatalf("Collect() error = %v, want canonical managed id collision", err) + } + + store := NewStore(projectRoot) + all, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(all) != 0 { + t.Fatalf("List() len = %d, want 0 after collision failure", len(all)) + } +} + +func TestCollectRules_RollsBackOnMidApplyWriteFailure(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + _, err := store.Put(Save{ + ID: "claude/existing-one.md", + Content: []byte("# Original One\n"), + }) + if err != nil { + t.Fatalf("seed Put() error = %v", err) + } + _, err = store.Put(Save{ + ID: "claude/existing-two.md", + Content: []byte("# Original Two\n"), + }) + if err != nil { + t.Fatalf("second seed Put() error = %v", err) + } + + discovered := []inspect.RuleItem{ + { + Name: "existing-one.md", + SourceTool: "claude", + Scope: inspect.ScopeProject, + Path: "/tmp/project/.claude/rules/existing-one.md", + Content: "# Updated One\n", + Collectible: true, + }, + { + Name: "existing-two.md", + SourceTool: "claude", + Scope: inspect.ScopeProject, + Path: "/tmp/project/.claude/rules/existing-two.md", + Content: "# Updated Two\n", + Collectible: true, + }, + } + + origWrite := ruleWriteFile + defer func() { ruleWriteFile = origWrite }() + + writeCalls := 0 + ruleWriteFile = func(name string, data []byte, perm os.FileMode) error { + writeCalls++ + if writeCalls == 2 { + // Simulate a partial current write before failure. + _ = origWrite(name, []byte("# CORRUPT\n"), perm) + return errors.New("injected write failure") + } + return origWrite(name, data, perm) + } + + _, err = Collect(projectRoot, discovered, CollectOptions{Strategy: StrategyOverwrite}) + if err == nil { + t.Fatal("Collect() error = nil, want injected write failure") + } + + existingOne, err := store.Get("claude/existing-one.md") + if err != nil { + t.Fatalf("Get(existing-one) error = %v", err) + } + if string(existingOne.Content) != "# Original One\n" { + t.Fatalf("existing-one content = %q, want %q", string(existingOne.Content), "# Original One\n") + } + + existingTwo, err := store.Get("claude/existing-two.md") + if err != nil { + t.Fatalf("Get(existing-two) error = %v", err) + } + if string(existingTwo.Content) != "# Original Two\n" { + t.Fatalf("existing-two content = %q, want %q", string(existingTwo.Content), "# Original Two\n") + } + + all, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(all) != 2 { + t.Fatalf("List() len = %d, want 2", len(all)) + } + if all[0].ID != "claude/existing-one.md" { + t.Fatalf("List()[0].ID = %q, want %q", all[0].ID, "claude/existing-one.md") + } + if string(all[0].Content) != "# Original One\n" { + t.Fatalf("List()[0].Content = %q, want %q", string(all[0].Content), "# Original One\n") + } + if all[1].ID != "claude/existing-two.md" { + t.Fatalf("List()[1].ID = %q, want %q", all[1].ID, "claude/existing-two.md") + } + if string(all[1].Content) != "# Original Two\n" { + t.Fatalf("List()[1].Content = %q, want %q", string(all[1].Content), "# Original Two\n") + } +} + +func TestManagedIDForDiscoveredRule_PiFamily(t *testing.T) { + tests := []struct { + name string + item inspect.RuleItem + want string + }{ + { + name: "pi system file", + item: inspect.RuleItem{ + SourceTool: "pi", + Path: "/tmp/project/.pi/SYSTEM.md", + }, + want: "pi/SYSTEM.md", + }, + { + name: "pi append system file", + item: inspect.RuleItem{ + SourceTool: "pi", + Path: "/tmp/project/.pi/APPEND_SYSTEM.md", + }, + want: "pi/APPEND_SYSTEM.md", + }, + { + name: "global pi agents file", + item: inspect.RuleItem{ + SourceTool: "pi", + Path: "/tmp/home/.pi/agent/AGENTS.md", + }, + want: "pi/AGENTS.md", + }, + { + name: "global pi system file", + item: inspect.RuleItem{ + SourceTool: "pi", + Path: "/tmp/home/.pi/agent/SYSTEM.md", + }, + want: "pi/SYSTEM.md", + }, + { + name: "global pi append system file", + item: inspect.RuleItem{ + SourceTool: "pi", + Path: "/tmp/home/.pi/agent/APPEND_SYSTEM.md", + }, + want: "pi/APPEND_SYSTEM.md", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ManagedIDForDiscoveredRule(tt.item) + if err != nil { + t.Fatalf("ManagedIDForDiscoveredRule() error = %v", err) + } + if got != tt.want { + t.Fatalf("ManagedIDForDiscoveredRule() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestManagedIDForDiscoveredRule_RejectsUnsupportedPiPath(t *testing.T) { + _, err := ManagedIDForDiscoveredRule(inspect.RuleItem{ + SourceTool: "pi", + Path: "/tmp/project/.pi/extra.md", + }) + if err == nil { + t.Fatal("ManagedIDForDiscoveredRule() error = nil, want unsupported pi path error") + } + if !errors.Is(err, ErrInvalidCollect) { + t.Fatalf("ManagedIDForDiscoveredRule() error = %v, want ErrInvalidCollect", err) + } + if !strings.Contains(err.Error(), "unsupported pi rule path") { + t.Fatalf("ManagedIDForDiscoveredRule() error = %v, want unsupported pi rule path message", err) + } +} + +func TestCollectRules_ScanRulesKeepsProjectRootAgentsUnderCodex(t *testing.T) { + projectRoot := t.TempDir() + agentsPath := filepath.Join(projectRoot, "AGENTS.md") + if err := os.WriteFile(agentsPath, []byte("# Root Agents\n"), 0644); err != nil { + t.Fatalf("WriteFile(AGENTS.md) error = %v", err) + } + + discovered, warnings, err := inspect.ScanRules(projectRoot) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("ScanRules() warnings = %v, want none", warnings) + } + + var rootAgents inspect.RuleItem + found := false + for _, item := range discovered { + if item.Path == agentsPath { + rootAgents = item + found = true + break + } + } + if !found { + t.Fatalf("ScanRules() items = %#v, want discovered root AGENTS", discovered) + } + if rootAgents.SourceTool != "codex" { + t.Fatalf("root AGENTS SourceTool = %q, want %q", rootAgents.SourceTool, "codex") + } + + result, err := Collect(projectRoot, []inspect.RuleItem{rootAgents}, CollectOptions{Strategy: StrategyOverwrite}) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + if len(result.Created) != 1 || result.Created[0] != "codex/AGENTS.md" { + t.Fatalf("Collect() Created = %v, want [codex/AGENTS.md]", result.Created) + } +} diff --git a/internal/resources/rules/compile.go b/internal/resources/rules/compile.go new file mode 100644 index 00000000..99a26a65 --- /dev/null +++ b/internal/resources/rules/compile.go @@ -0,0 +1,168 @@ +package rules + +import ( + "fmt" + "path" + "path/filepath" + "sort" + "strings" + + "skillshare/internal/resources/adapters" +) + +type CompiledFile = adapters.CompiledFile + +// CompileTarget compiles managed rule records into target-native files. +func CompileTarget(records []Record, targetFamily, targetName, projectRoot string) ([]CompiledFile, []string, error) { + targetFamily = strings.ToLower(strings.TrimSpace(targetFamily)) + targetName = strings.TrimSpace(targetName) + if targetFamily == "" { + return nil, nil, fmt.Errorf("target is required") + } + if targetName == "" { + targetName = targetFamily + } + + var ( + converted []adapters.RuleRecord + warnings []string + ) + + for _, record := range records { + if record.Disabled || !matchesAssignedTarget(record.Targets, targetName) { + continue + } + adapterRecord, warn, err := normalizeRecord(record) + if err != nil { + return nil, nil, err + } + if warn != "" { + warnings = append(warnings, warn) + continue + } + if adapterRecord.Tool != targetFamily { + continue + } + converted = append(converted, adapterRecord) + } + + sort.Slice(converted, func(i, j int) bool { + return converted[i].RelativePath < converted[j].RelativePath + }) + + var ( + files []CompiledFile + adapterWarnings []string + err error + ) + + switch targetFamily { + case "claude": + files, adapterWarnings, err = adapters.CompileClaudeRules(converted, projectRoot) + case "codex": + files, adapterWarnings, err = adapters.CompileCodexRules(converted, projectRoot) + case "gemini": + files, adapterWarnings, err = adapters.CompileGeminiRules(converted, projectRoot) + case "pi": + files, adapterWarnings, err = adapters.CompilePiRules(converted, projectRoot) + default: + return nil, nil, fmt.Errorf("%w %q", ErrUnsupportedTarget, targetFamily) + } + if len(converted) == 0 { + return nil, warnings, nil + } + if err != nil { + return nil, nil, err + } + + warnings = append(warnings, adapterWarnings...) + return files, warnings, nil +} + +func matchesAssignedTarget(targets []string, targetName string) bool { + normalized := normalizeAssignedTargets(targets) + if len(normalized) == 0 { + return true + } + for _, target := range normalized { + if target == targetName { + return true + } + } + return false +} + +func normalizeAssignedTargets(targets []string) []string { + if len(targets) == 0 { + return nil + } + out := make([]string, 0, len(targets)) + seen := make(map[string]struct{}, len(targets)) + for _, target := range targets { + target = strings.TrimSpace(target) + if target == "" { + continue + } + if _, ok := seen[target]; ok { + continue + } + seen[target] = struct{}{} + out = append(out, target) + } + if len(out) == 0 { + return nil + } + return out +} + +func normalizeRecord(record Record) (adapters.RuleRecord, string, error) { + rel := strings.TrimSpace(record.RelativePath) + if rel == "" { + rel = strings.TrimSpace(record.ID) + } + rel = filepath.ToSlash(rel) + if rel != "" { + rel = path.Clean(rel) + } + if rel == "." { + rel = "" + } + if strings.HasPrefix(rel, "../") || strings.HasPrefix(rel, "/") { + return adapters.RuleRecord{}, "", fmt.Errorf("invalid managed rule path %q", rel) + } + + tool := strings.ToLower(strings.TrimSpace(record.Tool)) + if tool == "" && rel != "" { + parts := strings.SplitN(rel, "/", 2) + if len(parts) > 1 { + tool = strings.ToLower(parts[0]) + } + } + if tool == "" { + return adapters.RuleRecord{}, fmt.Sprintf("skipping rule %q: missing tool", record.ID), nil + } + + if rel == "" { + name := strings.TrimSpace(record.Name) + if name == "" { + return adapters.RuleRecord{}, fmt.Sprintf("skipping rule %q: missing relative path", record.ID), nil + } + rel = tool + "/" + name + } + if !strings.HasPrefix(rel, tool+"/") { + rel = tool + "/" + strings.TrimPrefix(rel, "/") + } + + name := strings.TrimSpace(record.Name) + if name == "" { + name = path.Base(rel) + } + + return adapters.RuleRecord{ + ID: record.ID, + Tool: tool, + RelativePath: rel, + Name: name, + Content: string(record.Content), + }, "", nil +} diff --git a/internal/resources/rules/compile_test.go b/internal/resources/rules/compile_test.go new file mode 100644 index 00000000..37ae0c62 --- /dev/null +++ b/internal/resources/rules/compile_test.go @@ -0,0 +1,137 @@ +package rules + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestCompileRulesForTargets(t *testing.T) { + projectRoot := "/tmp/project" + ruleSet := []Record{ + {ID: "claude/CLAUDE.md", Content: []byte("# Claude Root\n")}, + {ID: "claude/backend.md", Content: []byte("# Claude Backend\n")}, + {ID: "codex/AGENTS.md", Content: []byte("# Codex Root\n")}, + {ID: "codex/backend.md", Content: []byte("# Codex Backend\n")}, + {ID: "gemini/GEMINI.md", Content: []byte("# Gemini Root\n")}, + {ID: "gemini/backend.md", Content: []byte("# Gemini Backend\n")}, + {ID: "pi/AGENTS.md", Content: []byte("# Pi Root\n")}, + {ID: "pi/SYSTEM.md", Content: []byte("# Pi System\n")}, + {ID: "pi/APPEND_SYSTEM.md", Content: []byte("# Pi Append\n")}, + } + + codexFiles, warnings, err := CompileTarget(ruleSet, "codex", "codex", projectRoot) + if err != nil { + t.Fatalf("CompileTarget(codex) error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompileTarget(codex) warnings = %v, want none", warnings) + } + agentsPath := filepath.Join(projectRoot, "AGENTS.md") + agentsContent := mustFindCompiledContent(t, codexFiles, agentsPath) + if !strings.Contains(agentsContent, "") { + t.Fatalf("AGENTS output missing backend marker; content = %q", agentsContent) + } + if !strings.Contains(agentsContent, "# Codex Backend") { + t.Fatalf("AGENTS output missing codex backend content; content = %q", agentsContent) + } + + claudeFiles, warnings, err := CompileTarget(ruleSet, "claude", "claude", projectRoot) + if err != nil { + t.Fatalf("CompileTarget(claude) error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompileTarget(claude) warnings = %v, want none", warnings) + } + _ = mustFindCompiledContent(t, claudeFiles, filepath.Join(projectRoot, "CLAUDE.md")) + _ = mustFindCompiledContent(t, claudeFiles, filepath.Join(projectRoot, ".claude", "rules", "backend.md")) + + geminiFiles, warnings, err := CompileTarget(ruleSet, "gemini", "gemini", projectRoot) + if err != nil { + t.Fatalf("CompileTarget(gemini) error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompileTarget(gemini) warnings = %v, want none", warnings) + } + _ = mustFindCompiledContent(t, geminiFiles, filepath.Join(projectRoot, "GEMINI.md")) + _ = mustFindCompiledContent(t, geminiFiles, filepath.Join(projectRoot, ".gemini", "rules", "backend.md")) + + piFiles, warnings, err := CompileTarget(ruleSet, "pi", "pi", projectRoot) + if err != nil { + t.Fatalf("CompileTarget(pi) error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompileTarget(pi) warnings = %v, want none", warnings) + } + _ = mustFindCompiledContent(t, piFiles, filepath.Join(projectRoot, "AGENTS.md")) + _ = mustFindCompiledContent(t, piFiles, filepath.Join(projectRoot, ".pi", "SYSTEM.md")) + _ = mustFindCompiledContent(t, piFiles, filepath.Join(projectRoot, ".pi", "APPEND_SYSTEM.md")) +} + +func TestCompileRulesForTargets_NestedInstructionNamesStayNested(t *testing.T) { + projectRoot := "/tmp/project" + ruleSet := []Record{ + {ID: "claude/nested/CLAUDE.md", Content: []byte("# Nested Claude\n")}, + {ID: "gemini/nested/GEMINI.md", Content: []byte("# Nested Gemini\n")}, + } + + claudeFiles, warnings, err := CompileTarget(ruleSet, "claude", "claude", projectRoot) + if err != nil { + t.Fatalf("CompileTarget(claude) error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompileTarget(claude) warnings = %v, want none", warnings) + } + _ = mustFindCompiledContent(t, claudeFiles, filepath.Join(projectRoot, ".claude", "rules", "nested", "CLAUDE.md")) + mustNotContainCompiledPath(t, claudeFiles, filepath.Join(projectRoot, "CLAUDE.md")) + + geminiFiles, warnings, err := CompileTarget(ruleSet, "gemini", "gemini", projectRoot) + if err != nil { + t.Fatalf("CompileTarget(gemini) error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("CompileTarget(gemini) warnings = %v, want none", warnings) + } + _ = mustFindCompiledContent(t, geminiFiles, filepath.Join(projectRoot, ".gemini", "rules", "nested", "GEMINI.md")) + mustNotContainCompiledPath(t, geminiFiles, filepath.Join(projectRoot, "GEMINI.md")) +} + +func TestCompileRulesForTargets_SkipsWhenTargetNotAssigned(t *testing.T) { + files, warnings, err := CompileTarget([]Record{{ + ID: "claude/manual.md", + Tool: "claude", + Name: "manual.md", + Content: []byte("# Manual\n"), + Targets: []string{"claude-work"}, + SourceType: "local", + }}, "claude", "claude-personal", t.TempDir()) + if err != nil { + t.Fatalf("CompileTarget() error = %v", err) + } + if len(files) != 0 { + t.Fatalf("CompileTarget() files = %v, want none", files) + } + if len(warnings) != 0 { + t.Fatalf("CompileTarget() warnings = %v, want none", warnings) + } +} + +func mustFindCompiledContent(t *testing.T, files []CompiledFile, path string) string { + t.Helper() + for _, file := range files { + if file.Path == path { + return file.Content + } + } + t.Fatalf("compiled output missing path %q", path) + return "" +} + +func mustNotContainCompiledPath(t *testing.T, files []CompiledFile, path string) { + t.Helper() + for _, file := range files { + if file.Path == path { + t.Fatalf("compiled output unexpectedly contained path %q", path) + } + } +} diff --git a/internal/resources/rules/errors.go b/internal/resources/rules/errors.go new file mode 100644 index 00000000..e93f4450 --- /dev/null +++ b/internal/resources/rules/errors.go @@ -0,0 +1,9 @@ +package rules + +import "errors" + +var ( + ErrInvalidID = errors.New("invalid rule id") + ErrInvalidCollect = errors.New("invalid collect request") + ErrUnsupportedTarget = errors.New("unsupported target") +) diff --git a/internal/resources/rules/metadata.go b/internal/resources/rules/metadata.go new file mode 100644 index 00000000..06d0c698 --- /dev/null +++ b/internal/resources/rules/metadata.go @@ -0,0 +1,116 @@ +package rules + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + ruleMetadataSuffix = ".metadata.yaml" + ruleMetadataTempPrefix = ".rule-metadata-tmp-" +) + +type ruleMetadata struct { + Targets []string `yaml:"targets,omitempty"` + SourceType string `yaml:"sourceType,omitempty"` + Disabled bool `yaml:"disabled,omitempty"` +} + +func loadRuleMetadata(rulePath string) (ruleMetadata, error) { + data, err := os.ReadFile(ruleMetadataPath(rulePath)) + if err != nil { + if os.IsNotExist(err) { + return ruleMetadata{}, nil + } + return ruleMetadata{}, err + } + + var metadata ruleMetadata + if err := yaml.Unmarshal(data, &metadata); err != nil { + return ruleMetadata{}, fmt.Errorf("parse rule metadata %q: %w", ruleMetadataPath(rulePath), err) + } + return sanitizeRuleMetadata(metadata), nil +} + +func saveRuleMetadata(rulePath string, metadata ruleMetadata) error { + metadata = sanitizeRuleMetadata(metadata) + metadataPath := ruleMetadataPath(rulePath) + if metadata.isZero() { + if err := os.Remove(metadataPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + + data, err := yaml.Marshal(metadata) + if err != nil { + return fmt.Errorf("marshal rule metadata: %w", err) + } + + dir := filepath.Dir(metadataPath) + tempFile, err := os.CreateTemp(dir, ruleMetadataTempPrefix+"*") + if err != nil { + return fmt.Errorf("create metadata temp file: %w", err) + } + + tempPath := tempFile.Name() + closeWithCleanup := func(writeErr error) error { + _ = tempFile.Close() + _ = os.Remove(tempPath) + return writeErr + } + + if err := tempFile.Close(); err != nil { + return closeWithCleanup(fmt.Errorf("close metadata temp file: %w", err)) + } + if err := ruleWriteFile(tempPath, data, 0644); err != nil { + return closeWithCleanup(err) + } + if err := replaceRuleFile(tempPath, metadataPath); err != nil { + _ = os.Remove(tempPath) + return fmt.Errorf("rename metadata temp file: %w", err) + } + return nil +} + +func deleteRuleMetadata(rulePath string) error { + if err := os.Remove(ruleMetadataPath(rulePath)); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func ruleMetadataPath(rulePath string) string { + base := filepath.Base(rulePath) + return filepath.Join(filepath.Dir(rulePath), "."+base+ruleMetadataSuffix) +} + +func isRuleMetadataFile(name string) bool { + return strings.HasPrefix(name, ".") && strings.HasSuffix(name, ruleMetadataSuffix) +} + +func sanitizeRuleMetadata(metadata ruleMetadata) ruleMetadata { + if len(metadata.Targets) > 0 { + targets := make([]string, 0, len(metadata.Targets)) + for _, target := range metadata.Targets { + target = strings.TrimSpace(target) + if target == "" { + continue + } + targets = append(targets, target) + } + metadata.Targets = targets + } else { + metadata.Targets = nil + } + metadata.SourceType = strings.TrimSpace(metadata.SourceType) + return metadata +} + +func (m ruleMetadata) isZero() bool { + return len(m.Targets) == 0 && m.SourceType == "" && !m.Disabled +} diff --git a/internal/resources/rules/metadata_test.go b/internal/resources/rules/metadata_test.go new file mode 100644 index 00000000..e60b3ddb --- /dev/null +++ b/internal/resources/rules/metadata_test.go @@ -0,0 +1,73 @@ +package rules + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestRuleMetadata_RoundTrip(t *testing.T) { + rulePath := filepath.Join(t.TempDir(), "claude", "manual.md") + if err := os.MkdirAll(filepath.Dir(rulePath), 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + want := ruleMetadata{ + Targets: []string{"claude-work", "claude-personal"}, + SourceType: "local", + Disabled: true, + } + if err := saveRuleMetadata(rulePath, want); err != nil { + t.Fatalf("saveRuleMetadata() error = %v", err) + } + + got, err := loadRuleMetadata(rulePath) + if err != nil { + t.Fatalf("loadRuleMetadata() error = %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("loadRuleMetadata() = %#v, want %#v", got, want) + } +} + +func TestRuleMetadata_MissingSidecarReturnsDefaults(t *testing.T) { + rulePath := filepath.Join(t.TempDir(), "claude", "manual.md") + got, err := loadRuleMetadata(rulePath) + if err != nil { + t.Fatalf("loadRuleMetadata() error = %v", err) + } + if !reflect.DeepEqual(got, ruleMetadata{}) { + t.Fatalf("loadRuleMetadata() = %#v, want zero metadata", got) + } +} + +func TestRuleMetadata_ZeroValueRemovesSidecar(t *testing.T) { + rulePath := filepath.Join(t.TempDir(), "claude", "manual.md") + if err := os.MkdirAll(filepath.Dir(rulePath), 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + if err := saveRuleMetadata(rulePath, ruleMetadata{Targets: []string{"claude-work"}}); err != nil { + t.Fatalf("saveRuleMetadata(non-zero) error = %v", err) + } + if err := saveRuleMetadata(rulePath, ruleMetadata{}); err != nil { + t.Fatalf("saveRuleMetadata(zero) error = %v", err) + } + + if _, err := os.Stat(ruleMetadataPath(rulePath)); !os.IsNotExist(err) { + t.Fatalf("metadata sidecar stat error = %v, want not-exist", err) + } +} + +func TestIsRuleMetadataFile(t *testing.T) { + if !isRuleMetadataFile(".manual.md.metadata.yaml") { + t.Fatal("expected metadata file name to be recognized") + } + if isRuleMetadataFile("manual.md") { + t.Fatal("expected plain markdown file to not be recognized as metadata") + } + if isRuleMetadataFile(".rule-tmp-123") { + t.Fatal("expected transient temp file to not be recognized as metadata") + } +} diff --git a/internal/resources/rules/replace_nonwindows.go b/internal/resources/rules/replace_nonwindows.go new file mode 100644 index 00000000..55a42a10 --- /dev/null +++ b/internal/resources/rules/replace_nonwindows.go @@ -0,0 +1,9 @@ +//go:build !windows + +package rules + +import "os" + +func replaceRuleFile(tempPath, fullPath string) error { + return os.Rename(tempPath, fullPath) +} diff --git a/internal/resources/rules/replace_windows.go b/internal/resources/rules/replace_windows.go new file mode 100644 index 00000000..54a8e983 --- /dev/null +++ b/internal/resources/rules/replace_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package rules + +import "golang.org/x/sys/windows" + +func replaceRuleFile(tempPath, fullPath string) error { + return windows.Rename(tempPath, fullPath) +} diff --git a/internal/resources/rules/store.go b/internal/resources/rules/store.go new file mode 100644 index 00000000..0939586f --- /dev/null +++ b/internal/resources/rules/store.go @@ -0,0 +1,331 @@ +package rules + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "skillshare/internal/config" + managedpi "skillshare/internal/resources/managed/pi" +) + +var ( + ruleWriteFile = os.WriteFile + ruleSaveMetadata = saveRuleMetadata +) + +// Store persists managed rules as files under the managed rules root. +type Store struct { + root string +} + +// NewStore creates a rule store for global mode (empty projectRoot) or project mode. +func NewStore(projectRoot string) *Store { + return &Store{ + root: config.ManagedRulesDir(projectRoot), + } +} + +// Put writes the rule file for the provided ID. +func (s *Store) Put(in Save) (Record, error) { + fullPath, id, err := s.pathForID(in.ID) + if err != nil { + return Record{}, err + } + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + return Record{}, fmt.Errorf("create rule directory: %w", err) + } + previousContent, hadPreviousContent, err := readExistingRuleContent(fullPath) + if err != nil { + return Record{}, err + } + + tempPath, err := s.writeTempRule(filepath.Dir(fullPath), in.Content) + if err != nil { + return Record{}, fmt.Errorf("write rule: %w", err) + } + if err := s.replaceRuleFile(tempPath, fullPath); err != nil { + _ = os.Remove(tempPath) + return Record{}, fmt.Errorf("write rule: rename temp file: %w", err) + } + if err := ruleSaveMetadata(fullPath, ruleMetadata{ + Targets: in.Targets, + SourceType: in.SourceType, + Disabled: in.Disabled, + }); err != nil { + rollbackErr := s.restoreRuleContent(fullPath, previousContent, hadPreviousContent) + if rollbackErr != nil { + return Record{}, errors.Join( + fmt.Errorf("write rule metadata: %w", err), + fmt.Errorf("rollback rule content: %w", rollbackErr), + ) + } + return Record{}, fmt.Errorf("write rule metadata: %w", err) + } + + tool, name := splitRuleID(id) + return Record{ + ID: id, + Path: fullPath, + Tool: tool, + RelativePath: id, + Name: name, + Content: append([]byte(nil), in.Content...), + Targets: append([]string(nil), sanitizeRuleMetadata(ruleMetadata{Targets: in.Targets}).Targets...), + SourceType: strings.TrimSpace(in.SourceType), + Disabled: in.Disabled, + }, nil +} + +// Get loads one managed rule by ID. +func (s *Store) Get(id string) (Record, error) { + fullPath, cleanedID, err := s.pathForID(id) + if err != nil { + return Record{}, err + } + if err := ensureRegularRuleFile(fullPath, cleanedID); err != nil { + return Record{}, err + } + data, err := os.ReadFile(fullPath) + if err != nil { + return Record{}, err + } + metadata, err := loadRuleMetadata(fullPath) + if err != nil { + return Record{}, err + } + tool, name := splitRuleID(cleanedID) + return Record{ + ID: cleanedID, + Path: fullPath, + Tool: tool, + RelativePath: cleanedID, + Name: name, + Content: data, + Targets: metadata.Targets, + SourceType: metadata.SourceType, + Disabled: metadata.Disabled, + }, nil +} + +// List returns all managed rules under the store root. +func (s *Store) List() ([]Record, error) { + if _, err := os.Stat(s.root); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var out []Record + err := filepath.WalkDir(s.root, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + if isTransientRuleFile(d.Name()) || isRuleMetadataFile(d.Name()) { + return nil + } + info, err := os.Lstat(p) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + rel, err := filepath.Rel(s.root, p) + if err != nil { + return err + } + id := filepath.ToSlash(rel) + tool, name := splitRuleID(id) + metadata, err := loadRuleMetadata(p) + if err != nil { + return err + } + out = append(out, Record{ + ID: id, + Path: p, + Tool: tool, + RelativePath: id, + Name: name, + Content: data, + Targets: metadata.Targets, + SourceType: metadata.SourceType, + Disabled: metadata.Disabled, + }) + return nil + }) + if err != nil { + return nil, err + } + + sort.Slice(out, func(i, j int) bool { + return out[i].ID < out[j].ID + }) + return out, nil +} + +// Delete removes one managed rule by ID. +func (s *Store) Delete(id string) error { + fullPath, _, err := s.pathForID(id) + if err != nil { + return err + } + if err := os.Remove(fullPath); err != nil { + return err + } + if err := deleteRuleMetadata(fullPath); err != nil { + return err + } + return nil +} + +func (s *Store) pathForID(id string) (fullPath string, cleanedID string, err error) { + cleanedID, err = NormalizeRuleID(id) + if err != nil { + return "", "", err + } + + fullPath = filepath.Join(s.root, filepath.FromSlash(cleanedID)) + return fullPath, cleanedID, nil +} + +// NormalizeRuleID validates and canonicalizes a managed rule ID into slash form. +func NormalizeRuleID(id string) (string, error) { + normalized := strings.ReplaceAll(strings.TrimSpace(id), "\\", "/") + cleanedID := path.Clean(normalized) + + if cleanedID == "" || cleanedID == "." || cleanedID == ".." { + return "", fmt.Errorf("%w %q", ErrInvalidID, id) + } + if strings.HasPrefix(cleanedID, "/") || strings.HasPrefix(cleanedID, "../") { + return "", fmt.Errorf("%w %q", ErrInvalidID, id) + } + + parts := strings.Split(cleanedID, "/") + for _, part := range parts { + if len(part) >= 2 && part[1] == ':' { + return "", fmt.Errorf("%w %q", ErrInvalidID, id) + } + if isTransientRuleFile(part) || isRuleMetadataFile(part) { + return "", fmt.Errorf("%w %q", ErrInvalidID, id) + } + } + if !isSupportedRuleToolPrefix(parts[0]) { + return "", fmt.Errorf("%w %q", ErrInvalidID, id) + } + if len(parts) < 2 { + return "", fmt.Errorf("%w %q", ErrInvalidID, id) + } + if parts[0] == "pi" && !managedpi.IsManagedRuleID(cleanedID) { + return "", fmt.Errorf("%w %q", ErrInvalidID, id) + } + + return cleanedID, nil +} + +func (s *Store) writeTempRule(dir string, content []byte) (string, error) { + tempFile, err := os.CreateTemp(dir, ".rule-tmp-*") + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + + tempPath := tempFile.Name() + closeWithCleanup := func(writeErr error) (string, error) { + _ = tempFile.Close() + _ = os.Remove(tempPath) + return "", writeErr + } + + if err := tempFile.Close(); err != nil { + return closeWithCleanup(fmt.Errorf("close temp file: %w", err)) + } + + if err := ruleWriteFile(tempPath, content, 0644); err != nil { + return closeWithCleanup(err) + } + + return tempPath, nil +} + +func (s *Store) restoreRuleContent(fullPath string, content []byte, hadPreviousContent bool) error { + if !hadPreviousContent { + if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + + tempPath, err := s.writeTempRule(filepath.Dir(fullPath), content) + if err != nil { + return err + } + if err := s.replaceRuleFile(tempPath, fullPath); err != nil { + _ = os.Remove(tempPath) + return err + } + return nil +} + +func (s *Store) replaceRuleFile(tempPath, fullPath string) error { + return replaceRuleFile(tempPath, fullPath) +} + +func readExistingRuleContent(fullPath string) ([]byte, bool, error) { + data, err := os.ReadFile(fullPath) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + return data, true, nil +} + +func splitRuleID(id string) (tool string, name string) { + cleaned := path.Clean(strings.ReplaceAll(strings.TrimSpace(id), "\\", "/")) + parts := strings.Split(cleaned, "/") + if len(parts) > 0 { + tool = parts[0] + } + if len(parts) > 0 { + name = parts[len(parts)-1] + } + return tool, name +} + +func isSupportedRuleToolPrefix(tool string) bool { + switch tool { + case "claude", "codex", "gemini", "pi": + return true + default: + return false + } +} + +func isTransientRuleFile(name string) bool { + return strings.HasPrefix(name, ".rule-tmp-") || strings.HasPrefix(name, ruleMetadataTempPrefix) +} + +func ensureRegularRuleFile(fullPath, id string) error { + info, err := os.Lstat(fullPath) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("rule %q is not a regular file", id) + } + return nil +} diff --git a/internal/resources/rules/store_test.go b/internal/resources/rules/store_test.go new file mode 100644 index 00000000..d80f3983 --- /dev/null +++ b/internal/resources/rules/store_test.go @@ -0,0 +1,508 @@ +package rules + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "skillshare/internal/config" +) + +func TestManagedRulesDir_GlobalAndProject(t *testing.T) { + xdgHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgHome) + + globalRules := config.ManagedRulesDir("") + wantGlobalRules := filepath.Join(xdgHome, "skillshare", "rules") + if globalRules != wantGlobalRules { + t.Fatalf("ManagedRulesDir(\"\") = %q, want %q", globalRules, wantGlobalRules) + } + + globalHooks := config.ManagedHooksDir("") + wantGlobalHooks := filepath.Join(xdgHome, "skillshare", "hooks") + if globalHooks != wantGlobalHooks { + t.Fatalf("ManagedHooksDir(\"\") = %q, want %q", globalHooks, wantGlobalHooks) + } + + projectRoot := t.TempDir() + + projectRules := config.ManagedRulesDir(projectRoot) + wantProjectRules := filepath.Join(projectRoot, ".skillshare", "rules") + if projectRules != wantProjectRules { + t.Fatalf("ManagedRulesDir(project) = %q, want %q", projectRules, wantProjectRules) + } + + projectHooks := config.ManagedHooksDir(projectRoot) + wantProjectHooks := filepath.Join(projectRoot, ".skillshare", "hooks") + if projectHooks != wantProjectHooks { + t.Fatalf("ManagedHooksDir(project) = %q, want %q", projectHooks, wantProjectHooks) + } +} + +func TestRuleStore_PutGetListDelete(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + saved, err := store.Put(Save{ + ID: "claude/backend.md", + Content: []byte("# backend\n"), + }) + if err != nil { + t.Fatalf("Put() error = %v", err) + } + if saved.ID != "claude/backend.md" { + t.Fatalf("Put() ID = %q, want %q", saved.ID, "claude/backend.md") + } + + got, err := store.Get("claude/backend.md") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if string(got.Content) != "# backend\n" { + t.Fatalf("Get() content = %q, want %q", string(got.Content), "# backend\n") + } + + all, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(all) != 1 { + t.Fatalf("List() len = %d, want 1", len(all)) + } + if all[0].ID != "claude/backend.md" { + t.Fatalf("List()[0].ID = %q, want %q", all[0].ID, "claude/backend.md") + } + + if err := store.Delete("claude/backend.md"); err != nil { + t.Fatalf("Delete() error = %v", err) + } + + _, err = store.Get("claude/backend.md") + if !os.IsNotExist(err) { + t.Fatalf("Get() after Delete error = %v, want not-exist", err) + } +} + +func TestRuleStore_PutOverwritesExistingRule(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + _, err := store.Put(Save{ + ID: "claude/backend.md", + Content: []byte("# v1\n"), + }) + if err != nil { + t.Fatalf("first Put() error = %v", err) + } + + _, err = store.Put(Save{ + ID: "claude/backend.md", + Content: []byte("# v2\n"), + }) + if err != nil { + t.Fatalf("second Put() error = %v", err) + } + + got, err := store.Get("claude/backend.md") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if string(got.Content) != "# v2\n" { + t.Fatalf("Get() content = %q, want %q", string(got.Content), "# v2\n") + } + + all, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(all) != 1 { + t.Fatalf("List() len = %d, want 1", len(all)) + } + if all[0].ID != "claude/backend.md" { + t.Fatalf("List()[0].ID = %q, want %q", all[0].ID, "claude/backend.md") + } + if string(all[0].Content) != "# v2\n" { + t.Fatalf("List()[0].Content = %q, want %q", string(all[0].Content), "# v2\n") + } +} + +func TestRuleStore_PutAndGet_RoundTripsMetadataSidecar(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + content := []byte("---\npaths: [src/**]\n---\n# Manual rule\n") + saved, err := store.Put(Save{ + ID: "claude/manual.md", + Content: content, + Targets: []string{"claude-work", "claude-personal"}, + SourceType: "local", + Disabled: true, + }) + if err != nil { + t.Fatalf("Put() error = %v", err) + } + + got, err := store.Get(saved.ID) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + if string(got.Content) != string(content) { + t.Fatalf("content changed during round-trip: got %q want %q", string(got.Content), string(content)) + } + if !reflect.DeepEqual(got.Targets, []string{"claude-work", "claude-personal"}) { + t.Fatalf("Get() Targets = %#v, want %#v", got.Targets, []string{"claude-work", "claude-personal"}) + } + if got.SourceType != "local" { + t.Fatalf("Get() SourceType = %q, want %q", got.SourceType, "local") + } + if !got.Disabled { + t.Fatal("Get() Disabled = false, want true") + } + + rulePath := filepath.Join(projectRoot, ".skillshare", "rules", "claude", "manual.md") + raw, err := os.ReadFile(rulePath) + if err != nil { + t.Fatalf("ReadFile(rule) error = %v", err) + } + if string(raw) != string(content) { + t.Fatalf("rule file content changed: got %q want %q", string(raw), string(content)) + } +} + +func TestRuleStore_Get_LegacyRuleWithoutMetadataLoadsAsGlobalEnabled(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + rulePath := filepath.Join(projectRoot, ".skillshare", "rules", "codex", "backend.md") + if err := os.MkdirAll(filepath.Dir(rulePath), 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(rulePath, []byte("# Backend\n"), 0644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + got, err := store.Get("codex/backend.md") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if got.Disabled { + t.Fatal("Get() Disabled = true, want false") + } + if len(got.Targets) != 0 { + t.Fatalf("Get() Targets = %#v, want nil/empty", got.Targets) + } + if got.SourceType != "" { + t.Fatalf("Get() SourceType = %q, want empty", got.SourceType) + } +} + +func TestRuleStore_RejectsInvalidIDs(t *testing.T) { + store := NewStore(t.TempDir()) + + invalidIDs := []string{ + "", + " ", + ".", + "..", + "../outside.md", + "..\\outside.md", + "..\\..\\outside.md", + "/tmp/outside.md", + `C:\outside.md`, + "C:/outside.md", + "claude/C:/outside.md", + "claude/C:outside.md", + `\\server\share\file.md`, + "claude/.backend.md.metadata.yaml", + "claude/nested/.backend.md.metadata.yaml", + "claude/.rule-metadata-tmp-123", + "claude/nested/.rule-metadata-tmp-123", + } + + for _, id := range invalidIDs { + id := id + t.Run(id, func(t *testing.T) { + if _, err := store.Put(Save{ID: id, Content: []byte("x")}); err == nil { + t.Fatalf("Put(%q) error = nil, want error", id) + } + if _, err := store.Get(id); err == nil { + t.Fatalf("Get(%q) error = nil, want error", id) + } + if err := store.Delete(id); err == nil { + t.Fatalf("Delete(%q) error = nil, want error", id) + } + }) + } +} + +func TestRuleStore_Put_RollsBackContentWhenMetadataWriteFails(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + if _, err := store.Put(Save{ + ID: "claude/backend.md", + Content: []byte("# old\n"), + Targets: []string{"claude-work"}, + SourceType: "local", + }); err != nil { + t.Fatalf("seed Put() error = %v", err) + } + + originalRuleWriteFile := ruleWriteFile + ruleWriteFile = func(name string, data []byte, perm os.FileMode) error { + if isRuleMetadataFile(filepath.Base(name)) || strings.HasPrefix(filepath.Base(name), ruleMetadataTempPrefix) { + return errors.New("boom") + } + return originalRuleWriteFile(name, data, perm) + } + defer func() { + ruleWriteFile = originalRuleWriteFile + }() + + _, err := store.Put(Save{ + ID: "claude/backend.md", + Content: []byte("# new\n"), + Targets: []string{"claude-personal"}, + SourceType: "project", + Disabled: true, + }) + if err == nil { + t.Fatal("Put() error = nil, want metadata write failure") + } + + got, getErr := store.Get("claude/backend.md") + if getErr != nil { + t.Fatalf("Get() error = %v", getErr) + } + if string(got.Content) != "# old\n" { + t.Fatalf("Get() content = %q, want rollback to %q", string(got.Content), "# old\n") + } + if !reflect.DeepEqual(got.Targets, []string{"claude-work"}) { + t.Fatalf("Get() Targets = %#v, want %#v", got.Targets, []string{"claude-work"}) + } + if got.SourceType != "local" { + t.Fatalf("Get() SourceType = %q, want %q", got.SourceType, "local") + } + if got.Disabled { + t.Fatal("Get() Disabled = true, want false") + } +} + +func TestRuleStore_RejectsUnsupportedToolPrefixes(t *testing.T) { + store := NewStore(t.TempDir()) + + unsupportedIDs := []string{ + "foo/bar.md", + "hooks/rule.md", + "unknown/nested/rule.md", + } + + for _, id := range unsupportedIDs { + id := id + t.Run(id, func(t *testing.T) { + if _, err := store.Put(Save{ID: id, Content: []byte("x")}); err == nil { + t.Fatalf("Put(%q) error = nil, want error", id) + } + if _, err := store.Get(id); err == nil { + t.Fatalf("Get(%q) error = nil, want error", id) + } + if err := store.Delete(id); err == nil { + t.Fatalf("Delete(%q) error = nil, want error", id) + } + }) + } +} + +func TestNormalizeRuleID_RejectsBareToolPrefixes(t *testing.T) { + for _, id := range []string{"claude", "codex", "gemini", "pi"} { + t.Run(id, func(t *testing.T) { + if _, err := NormalizeRuleID(id); err == nil { + t.Fatalf("NormalizeRuleID(%q) error = nil, want error", id) + } + }) + } +} + +func TestNormalizeRuleID_AcceptsPiInstructionPaths(t *testing.T) { + for _, id := range []string{"pi/AGENTS.md", "pi/SYSTEM.md", "pi/APPEND_SYSTEM.md"} { + t.Run(id, func(t *testing.T) { + got, err := NormalizeRuleID(id) + if err != nil { + t.Fatalf("NormalizeRuleID(%q) error = %v", id, err) + } + if got != id { + t.Fatalf("NormalizeRuleID(%q) = %q, want %q", id, got, id) + } + }) + } +} + +func TestNormalizeRuleID_AcceptsNestedPiAgentsPaths(t *testing.T) { + for _, id := range []string{"pi/nested/AGENTS.md", "pi/nested/team/AGENTS.md"} { + t.Run(id, func(t *testing.T) { + got, err := NormalizeRuleID(id) + if err != nil { + t.Fatalf("NormalizeRuleID(%q) error = %v", id, err) + } + if got != id { + t.Fatalf("NormalizeRuleID(%q) = %q, want %q", id, got, id) + } + }) + } +} + +func TestNormalizeRuleID_RejectsUnsupportedPiPaths(t *testing.T) { + for _, id := range []string{"pi/extra.md", "pi/nested/SYSTEM.md"} { + t.Run(id, func(t *testing.T) { + if _, err := NormalizeRuleID(id); err == nil { + t.Fatalf("NormalizeRuleID(%q) error = nil, want error", id) + } + }) + } +} + +func TestNormalizeRuleID_RejectsHiddenNestedPiAgentsPaths(t *testing.T) { + for _, id := range []string{"pi/.codex/AGENTS.md", "pi/.gemini/AGENTS.md", "pi/.hidden/team/AGENTS.md"} { + t.Run(id, func(t *testing.T) { + if _, err := NormalizeRuleID(id); err == nil { + t.Fatalf("NormalizeRuleID(%q) error = nil, want error", id) + } + }) + } +} + +func TestNormalizeRuleID_RejectsReservedTempSegments(t *testing.T) { + for _, id := range []string{ + "claude/.rule-tmp-test.md", + "claude/rules/.rule-tmp-test.md", + "codex/.rule-tmp-agents.md", + "gemini/nested/.rule-tmp-rule.md", + } { + t.Run(id, func(t *testing.T) { + if _, err := NormalizeRuleID(id); err == nil { + t.Fatalf("NormalizeRuleID(%q) error = nil, want error", id) + } + }) + } +} + +func TestRuleStore_ListIgnoresTransientTempFiles(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + if _, err := store.Put(Save{ + ID: "claude/keep.md", + Content: []byte("# Keep\n"), + }); err != nil { + t.Fatalf("Put() error = %v", err) + } + + tempPath := filepath.Join(projectRoot, ".skillshare", "rules", "claude", ".rule-tmp-12345") + if err := os.WriteFile(tempPath, []byte("# Temp\n"), 0644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", tempPath, err) + } + + all, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(all) != 1 { + t.Fatalf("List() len = %d, want 1", len(all)) + } + if all[0].ID != "claude/keep.md" { + t.Fatalf("List()[0].ID = %q, want %q", all[0].ID, "claude/keep.md") + } +} + +func TestRuleStore_ListIgnoresMetadataTempFiles(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + if _, err := store.Put(Save{ + ID: "claude/keep.md", + Content: []byte("# Keep\n"), + }); err != nil { + t.Fatalf("Put() error = %v", err) + } + + tempPath := filepath.Join(projectRoot, ".skillshare", "rules", "claude", ".rule-metadata-tmp-12345") + if err := os.WriteFile(tempPath, []byte("targets:\n - stray\n"), 0644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", tempPath, err) + } + + all, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(all) != 1 { + t.Fatalf("List() len = %d, want 1", len(all)) + } + if all[0].ID != "claude/keep.md" { + t.Fatalf("List()[0].ID = %q, want %q", all[0].ID, "claude/keep.md") + } +} + +func TestRuleStore_ListIgnoresNonRegularEntries(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + if _, err := store.Put(Save{ + ID: "claude/keep.md", + Content: []byte("# Keep\n"), + }); err != nil { + t.Fatalf("Put() error = %v", err) + } + + targetPath := filepath.Join(projectRoot, "external.md") + if err := os.WriteFile(targetPath, []byte("# Linked\n"), 0644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", targetPath, err) + } + + linkPath := filepath.Join(projectRoot, ".skillshare", "rules", "claude", "linked.md") + if err := os.Symlink(targetPath, linkPath); err != nil { + t.Skipf("Symlink(%q, %q) unsupported: %v", targetPath, linkPath, err) + } + + all, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(all) != 1 { + t.Fatalf("List() len = %d, want 1", len(all)) + } + if all[0].ID != "claude/keep.md" { + t.Fatalf("List()[0].ID = %q, want %q", all[0].ID, "claude/keep.md") + } +} + +func TestRuleStore_GetRejectsNonRegularEntries(t *testing.T) { + projectRoot := t.TempDir() + store := NewStore(projectRoot) + + targetPath := filepath.Join(projectRoot, "external.md") + if err := os.WriteFile(targetPath, []byte("# Linked\n"), 0644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", targetPath, err) + } + + managedDir := filepath.Join(projectRoot, ".skillshare", "rules", "claude") + if err := os.MkdirAll(managedDir, 0755); err != nil { + t.Fatalf("MkdirAll(%q) error = %v", managedDir, err) + } + + linkPath := filepath.Join(managedDir, "linked.md") + if err := os.Symlink(targetPath, linkPath); err != nil { + t.Skipf("Symlink(%q, %q) unsupported: %v", targetPath, linkPath, err) + } + + _, err := store.Get("claude/linked.md") + if err == nil { + t.Fatalf("Get() error = nil, want non-regular file error") + } + if !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("Get() error = %v, want non-regular file error", err) + } +} diff --git a/internal/resources/rules/types.go b/internal/resources/rules/types.go new file mode 100644 index 00000000..9d1759b1 --- /dev/null +++ b/internal/resources/rules/types.go @@ -0,0 +1,23 @@ +package rules + +// Record is a managed rule loaded from the filesystem. +type Record struct { + ID string + Path string + Tool string + RelativePath string + Name string + Content []byte + Targets []string + SourceType string + Disabled bool +} + +// Save is the input payload for persisting a managed rule. +type Save struct { + ID string + Content []byte + Targets []string + SourceType string + Disabled bool +} diff --git a/internal/server/content_stats.go b/internal/server/content_stats.go new file mode 100644 index 00000000..558ab36f --- /dev/null +++ b/internal/server/content_stats.go @@ -0,0 +1,60 @@ +package server + +import ( + "strings" + "sync" + + tiktoken "github.com/pkoukk/tiktoken-go" +) + +type contentStats struct { + WordCount int `json:"wordCount"` + LineCount int `json:"lineCount"` + TokenCount int `json:"tokenCount"` +} + +var ( + cl100kEncoderOnce sync.Once + cl100kEncoder *tiktoken.Tiktoken + cl100kEncoderErr error +) + +func buildContentStats(content string) contentStats { + return contentStats{ + WordCount: countWords(content), + LineCount: countLines(content), + TokenCount: countTokens(content), + } +} + +func countWords(content string) int { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return 0 + } + return len(strings.Fields(trimmed)) +} + +func countLines(content string) int { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return 0 + } + normalized := strings.ReplaceAll(trimmed, "\r\n", "\n") + return len(strings.Split(normalized, "\n")) +} + +func countTokens(content string) int { + encoder, err := getCL100KEncoder() + if err != nil { + return 0 + } + return len(encoder.Encode(content, nil, nil)) +} + +func getCL100KEncoder() (*tiktoken.Tiktoken, error) { + cl100kEncoderOnce.Do(func() { + cl100kEncoder, cl100kEncoderErr = tiktoken.GetEncoding("cl100k_base") + }) + return cl100kEncoder, cl100kEncoderErr +} diff --git a/internal/server/content_stats_test.go b/internal/server/content_stats_test.go new file mode 100644 index 00000000..24ecffbe --- /dev/null +++ b/internal/server/content_stats_test.go @@ -0,0 +1,32 @@ +package server + +import "testing" + +func TestBuildContentStats_EmptyContent(t *testing.T) { + stats := buildContentStats("") + if stats.WordCount != 0 { + t.Fatalf("WordCount = %d, want 0", stats.WordCount) + } + if stats.LineCount != 0 { + t.Fatalf("LineCount = %d, want 0", stats.LineCount) + } + if stats.TokenCount != 0 { + t.Fatalf("TokenCount = %d, want 0", stats.TokenCount) + } +} + +func TestBuildContentStats_WordsAndLines(t *testing.T) { + stats := buildContentStats("one two\nthree\r\nfour") + if stats.WordCount != 4 { + t.Fatalf("WordCount = %d, want 4", stats.WordCount) + } + if stats.LineCount != 3 { + t.Fatalf("LineCount = %d, want 3", stats.LineCount) + } +} + +func TestCountTokens_TiktokenCompatibility(t *testing.T) { + if got := countTokens("tiktoken is great!"); got != 6 { + t.Fatalf("countTokens() = %d, want 6", got) + } +} diff --git a/internal/server/handler_create_skill.go b/internal/server/handler_create_skill.go index c4e73754..507cdf52 100644 --- a/internal/server/handler_create_skill.go +++ b/internal/server/handler_create_skill.go @@ -5,12 +5,18 @@ import ( "fmt" "net/http" "os" + "path" "path/filepath" + "regexp" + "strings" "time" + "skillshare/internal/resource" "skillshare/internal/skill" ) +var validAgentNameRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]*(/[a-z_][a-z0-9_-]*)*$`) + func (s *Server) handleGetTemplates(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{ "patterns": skill.Patterns, @@ -20,6 +26,7 @@ func (s *Server) handleGetTemplates(w http.ResponseWriter, r *http.Request) { type createSkillRequest struct { Name string `json:"name"` + Kind string `json:"kind"` Pattern string `json:"pattern"` Category string `json:"category"` ScaffoldDirs []string `json:"scaffoldDirs"` @@ -34,6 +41,20 @@ func (s *Server) handleCreateSkill(w http.ResponseWriter, r *http.Request) { return } + kind := req.Kind + if kind == "" { + kind = "skill" + } + if kind != "skill" && kind != "agent" { + writeError(w, http.StatusBadRequest, "invalid kind: "+kind) + return + } + + if kind == "agent" { + s.handleCreateAgent(w, start, req) + return + } + // Validate name if !skill.ValidNameRe.MatchString(req.Name) { writeError(w, http.StatusBadRequest, "invalid skill name: use lowercase letters, numbers, hyphens, underscores; must start with letter or underscore") @@ -121,6 +142,7 @@ func (s *Server) handleCreateSkill(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{ "skill": map[string]any{ "name": req.Name, + "kind": "skill", "flatName": req.Name, "relPath": req.Name, "sourcePath": skillDir, @@ -128,3 +150,71 @@ func (s *Server) handleCreateSkill(w http.ResponseWriter, r *http.Request) { "createdFiles": createdFiles, }) } + +func (s *Server) handleCreateAgent(w http.ResponseWriter, start time.Time, req createSkillRequest) { + normalized := normalizeAgentName(req.Name) + if !validAgentNameRe.MatchString(normalized) { + writeError(w, http.StatusBadRequest, "invalid agent name: use lowercase path segments separated by /, with letters, numbers, hyphens, and underscores") + return + } + + relPath := normalized + ".md" + displayName := path.Base(normalized) + + s.mu.Lock() + defer s.mu.Unlock() + + agentsSource := s.agentsSource() + agentPath := filepath.Join(agentsSource, filepath.FromSlash(relPath)) + + if _, err := os.Stat(agentPath); err == nil { + writeError(w, http.StatusConflict, fmt.Sprintf("agent '%s' already exists", normalized)) + return + } + + if err := os.MkdirAll(filepath.Dir(agentPath), 0o755); err != nil { + writeError(w, http.StatusInternalServerError, "failed to create directory: "+err.Error()) + return + } + + content := generateAgentContent(displayName) + if err := os.WriteFile(agentPath, []byte(content), 0o644); err != nil { + writeError(w, http.StatusInternalServerError, "failed to write agent file: "+err.Error()) + return + } + + s.writeOpsLog("create-agent", "ok", start, map[string]any{ + "name": normalized, + "scope": "ui", + }, "") + + w.WriteHeader(http.StatusCreated) + writeJSON(w, map[string]any{ + "skill": map[string]any{ + "name": displayName, + "kind": "agent", + "flatName": resource.AgentFlatName(relPath), + "relPath": relPath, + "sourcePath": agentPath, + }, + "createdFiles": []string{relPath}, + }) +} + +func normalizeAgentName(name string) string { + name = strings.TrimSpace(strings.ReplaceAll(name, "\\", "/")) + return strings.Trim(name, "/") +} + +func generateAgentContent(name string) string { + return fmt.Sprintf(`--- +name: %s +description: >- + Describe when this agent should be used. +--- + +# %s + +Describe this agent's role, scope, and constraints. +`, name, name) +} diff --git a/internal/server/handler_create_skill_test.go b/internal/server/handler_create_skill_test.go index ecb5ea0e..6750986e 100644 --- a/internal/server/handler_create_skill_test.go +++ b/internal/server/handler_create_skill_test.go @@ -219,6 +219,64 @@ func TestHandleCreateSkill_InvalidScaffoldDir(t *testing.T) { } } +func TestHandleCreateSkill_AgentSuccess(t *testing.T) { + s, src := newTestServer(t) + agentsDir := filepath.Join(filepath.Dir(src), "agents") + s.cfg.AgentsSource = agentsDir + + body := `{"name":"reviewer","kind":"agent"}` + req := httptest.NewRequest(http.MethodPost, "/api/resources", bytes.NewBufferString(body)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Skill struct { + Name string `json:"name"` + Kind string `json:"kind"` + FlatName string `json:"flatName"` + RelPath string `json:"relPath"` + SourcePath string `json:"sourcePath"` + } `json:"skill"` + CreatedFiles []string `json:"createdFiles"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.Skill.Name != "reviewer" { + t.Errorf("expected name 'reviewer', got %q", resp.Skill.Name) + } + if resp.Skill.Kind != "agent" { + t.Errorf("expected kind 'agent', got %q", resp.Skill.Kind) + } + if resp.Skill.FlatName != "reviewer.md" { + t.Errorf("expected flatName 'reviewer.md', got %q", resp.Skill.FlatName) + } + if resp.Skill.RelPath != "reviewer.md" { + t.Errorf("expected relPath 'reviewer.md', got %q", resp.Skill.RelPath) + } + + data, err := os.ReadFile(resp.Skill.SourcePath) + if err != nil { + t.Fatalf("agent file not created: %v", err) + } + content := string(data) + if !strings.Contains(content, "name: reviewer") { + t.Error("agent file missing 'name: reviewer'") + } + if !strings.Contains(content, "# reviewer") { + t.Error("agent file missing '# reviewer'") + } + + if len(resp.CreatedFiles) != 1 || resp.CreatedFiles[0] != "reviewer.md" { + t.Fatalf("expected createdFiles [reviewer.md], got %v", resp.CreatedFiles) + } +} + func TestHandleCreateSkill_NonePattern(t *testing.T) { s, src := newTestServer(t) diff --git a/internal/server/handler_helpers_test.go b/internal/server/handler_helpers_test.go index 32db1abb..b817bf9d 100644 --- a/internal/server/handler_helpers_test.go +++ b/internal/server/handler_helpers_test.go @@ -78,6 +78,53 @@ func newTestServerWithTargets(t *testing.T, targets map[string]string) (*Server, return s, sourceDir } +// newManagedProjectServer creates a project-mode server with one configured target. +func newManagedProjectServer(t *testing.T, targetName string) (*Server, string, string, string) { + t.Helper() + + tmp := t.TempDir() + homeDir := filepath.Join(tmp, "home") + projectRoot := filepath.Join(tmp, "project") + sourceDir := filepath.Join(tmp, "source") + targetPath := filepath.Join(tmp, "targets", targetName) + + t.Setenv("HOME", homeDir) + t.Setenv("XDG_STATE_HOME", filepath.Join(tmp, "state")) + + if err := os.MkdirAll(filepath.Join(projectRoot, ".skillshare"), 0755); err != nil { + t.Fatalf("failed to create project config dir: %v", err) + } + if err := os.MkdirAll(sourceDir, 0755); err != nil { + t.Fatalf("failed to create source dir: %v", err) + } + if err := os.MkdirAll(targetPath, 0755); err != nil { + t.Fatalf("failed to create target dir: %v", err) + } + + projectCfgPath := filepath.Join(projectRoot, ".skillshare", "config.yaml") + raw := "targets:\n- name: " + targetName + "\n path: " + targetPath + "\n" + if err := os.WriteFile(projectCfgPath, []byte(raw), 0644); err != nil { + t.Fatalf("failed to write project config: %v", err) + } + + projectCfg, err := config.LoadProject(projectRoot) + if err != nil { + t.Fatalf("failed to load project config: %v", err) + } + + targets, err := config.ResolveProjectTargets(projectRoot, projectCfg) + if err != nil { + t.Fatalf("failed to resolve project targets: %v", err) + } + + cfg := &config.Config{ + Source: sourceDir, + Targets: targets, + } + s := NewProject(cfg, projectCfg, projectRoot, "127.0.0.1:0", "", "") + return s, projectRoot, sourceDir, targetPath +} + // addSkill creates a skill directory with SKILL.md in the source directory. func addSkill(t *testing.T, sourceDir, name string) { t.Helper() diff --git a/internal/server/handler_hooks.go b/internal/server/handler_hooks.go new file mode 100644 index 00000000..c51b1dc4 --- /dev/null +++ b/internal/server/handler_hooks.go @@ -0,0 +1,25 @@ +package server + +import ( + "net/http" + + "skillshare/internal/inspect" +) + +func (s *Server) handleListHooks(w http.ResponseWriter, r *http.Request) { + projectRoot := "" + if s.IsProjectMode() { + projectRoot = s.projectRoot + } + + items, warnings, err := inspect.ScanHooks(projectRoot) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to scan hooks: "+err.Error()) + return + } + + writeJSON(w, map[string]any{ + "hooks": items, + "warnings": warnings, + }) +} diff --git a/internal/server/handler_hooks_test.go b/internal/server/handler_hooks_test.go new file mode 100644 index 00000000..9fc1372f --- /dev/null +++ b/internal/server/handler_hooks_test.go @@ -0,0 +1,96 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "skillshare/internal/config" +) + +func TestHandleListHooks_Empty(t *testing.T) { + tmp := t.TempDir() + homeDir := filepath.Join(tmp, "home") + t.Setenv("HOME", homeDir) + t.Setenv("XDG_STATE_HOME", filepath.Join(tmp, "state")) + + cfgPath := filepath.Join(tmp, "config", "config.yaml") + t.Setenv("SKILLSHARE_CONFIG", cfgPath) + os.MkdirAll(filepath.Dir(cfgPath), 0755) + os.WriteFile(cfgPath, []byte("source: "+filepath.Join(tmp, "skills")+"\nmode: merge\ntargets: {}\n"), 0644) + + cfg, err := config.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + s := New(cfg, "127.0.0.1:0", "", "") + + req := httptest.NewRequest(http.MethodGet, "/api/hooks", nil) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Hooks []any `json:"hooks"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(resp.Hooks) != 0 { + t.Fatalf("expected 0 hooks, got %d", len(resp.Hooks)) + } +} + +func TestHandleListHooks_UsesProjectRoot(t *testing.T) { + tmp := t.TempDir() + homeDir := filepath.Join(tmp, "home") + projectRoot := filepath.Join(tmp, "project") + t.Setenv("HOME", homeDir) + t.Setenv("XDG_STATE_HOME", filepath.Join(tmp, "state")) + + homeHooksDir := filepath.Join(homeDir, ".claude") + os.MkdirAll(homeHooksDir, 0755) + os.WriteFile(filepath.Join(homeHooksDir, "settings.json"), []byte(`{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"home"}]}]}}`), 0644) + + projectHooksDir := filepath.Join(projectRoot, ".claude") + os.MkdirAll(projectHooksDir, 0755) + os.WriteFile(filepath.Join(projectHooksDir, "settings.json"), []byte(`{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"project"}]}]}}`), 0644) + + projectCfgDir := filepath.Join(projectRoot, ".skillshare") + os.MkdirAll(projectCfgDir, 0755) + os.WriteFile(filepath.Join(projectCfgDir, "config.yaml"), []byte("targets: []\n"), 0644) + + cfg := &config.Config{Source: filepath.Join(tmp, "skills"), Targets: map[string]config.TargetConfig{}} + s := NewProject(cfg, &config.ProjectConfig{}, projectRoot, "127.0.0.1:0", "", "") + + req := httptest.NewRequest(http.MethodGet, "/api/hooks", nil) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Hooks []map[string]any `json:"hooks"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(resp.Hooks) != 2 { + t.Fatalf("expected 2 hooks, got %d", len(resp.Hooks)) + } + for _, item := range resp.Hooks { + if item["command"] == "project" { + return + } + } + t.Fatal("expected project hook command in response") +} diff --git a/internal/server/handler_managed_capabilities.go b/internal/server/handler_managed_capabilities.go new file mode 100644 index 00000000..dec02c7b --- /dev/null +++ b/internal/server/handler_managed_capabilities.go @@ -0,0 +1,15 @@ +package server + +import ( + "net/http" + + "skillshare/internal/resources/managed" +) + +func (s *Server) handleManagedCapabilities(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + targets := s.cloneTargets() + s.mu.RUnlock() + + writeJSON(w, managed.CapabilitySnapshotForTargets(targets)) +} diff --git a/internal/server/handler_managed_capabilities_test.go b/internal/server/handler_managed_capabilities_test.go new file mode 100644 index 00000000..f9dda685 --- /dev/null +++ b/internal/server/handler_managed_capabilities_test.go @@ -0,0 +1,142 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "skillshare/internal/config" +) + +func TestHandleManagedCapabilities_ReturnsFamiliesAndTargets(t *testing.T) { + tmp := t.TempDir() + s, _ := newTestServerWithTargets(t, map[string]string{ + "pi": filepath.Join(tmp, "home", ".pi", "agent", "skills"), + }) + + req := httptest.NewRequest(http.MethodGet, "/api/managed/capabilities", nil) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Families map[string]struct { + SupportsRules bool `json:"SupportsRules"` + SupportsHooks bool `json:"SupportsHooks"` + } `json:"Families"` + Targets map[string]struct { + RulesFamily string `json:"rulesFamily"` + HooksFamily string `json:"hooksFamily"` + } `json:"Targets"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + pi, ok := resp.Families["pi"] + if !ok { + t.Fatal("expected pi family in response") + } + if !pi.SupportsRules || pi.SupportsHooks { + t.Fatalf("pi family = %#v, want rules-only support", pi) + } + + piTarget, ok := resp.Targets["pi"] + if !ok { + t.Fatal("expected pi target in response") + } + if piTarget.RulesFamily != "pi" || piTarget.HooksFamily != "" { + t.Fatalf("pi target = %#v, want rules family only", piTarget) + } + if _, ok := resp.Targets["claude"]; ok { + t.Fatalf("configured targets = %#v, want unrelated default targets excluded", resp.Targets) + } +} + +func TestHandleManagedCapabilities_UsesConfiguredTargets(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude-code") + + req := httptest.NewRequest(http.MethodGet, "/api/managed/capabilities", nil) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Targets map[string]struct { + RulesFamily string `json:"rulesFamily"` + HooksFamily string `json:"hooksFamily"` + } `json:"Targets"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + custom, ok := resp.Targets["claude-code"] + if !ok { + t.Fatalf("configured targets = %#v, want claude-code entry", resp.Targets) + } + if custom.RulesFamily != "claude" || custom.HooksFamily != "claude" { + t.Fatalf("claude-code target = %#v, want claude rules/hooks family", custom) + } + if _, ok := resp.Targets["pi"]; ok { + t.Fatalf("configured targets = %#v, want built-in defaults excluded", resp.Targets) + } + if _, ok := resp.Targets["claude"]; ok { + t.Fatalf("configured targets = %#v, want configured alias name preserved", resp.Targets) + } +} + +func TestHandleManagedCapabilities_UsesNestedSkillsConfigPath(t *testing.T) { + s, _ := newTestServer(t) + + s.mu.Lock() + s.cfg.Targets = map[string]config.TargetConfig{ + "my-codex": { + Skills: &config.ResourceTargetConfig{ + Path: filepath.Join(t.TempDir(), "home", ".agents", "skills"), + }, + }, + } + if err := s.cfg.Save(); err != nil { + s.mu.Unlock() + t.Fatalf("save config: %v", err) + } + s.mu.Unlock() + + req := httptest.NewRequest(http.MethodGet, "/api/managed/capabilities", nil) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Targets map[string]struct { + RulesFamily string `json:"rulesFamily"` + HooksFamily string `json:"hooksFamily"` + } `json:"Targets"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + custom, ok := resp.Targets["my-codex"] + if !ok { + t.Fatalf("configured targets = %#v, want my-codex entry", resp.Targets) + } + if custom.RulesFamily != "codex" || custom.HooksFamily != "codex" { + t.Fatalf("my-codex target = %#v, want codex rules/hooks family", custom) + } + if _, ok := resp.Targets["pi"]; ok { + t.Fatalf("configured targets = %#v, want built-in defaults excluded", resp.Targets) + } +} diff --git a/internal/server/handler_managed_hooks.go b/internal/server/handler_managed_hooks.go new file mode 100644 index 00000000..1a217693 --- /dev/null +++ b/internal/server/handler_managed_hooks.go @@ -0,0 +1,744 @@ +package server + +import ( + "errors" + "net/http" + "os" + "sort" + "strings" + + "skillshare/internal/inspect" + managedhooks "skillshare/internal/resources/hooks" + managed "skillshare/internal/resources/managed" +) + +type managedHookHandlerPayload struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Command string `json:"command,omitempty"` + URL string `json:"url,omitempty"` + Prompt string `json:"prompt,omitempty"` + Timeout string `json:"timeout,omitempty"` + TimeoutSeconds *int `json:"timeoutSec,omitempty"` + StatusMessage string `json:"statusMessage,omitempty"` +} + +type managedHookPayload struct { + ID string `json:"id"` + Tool string `json:"tool"` + Event string `json:"event"` + Matcher string `json:"matcher"` + Sequential *bool `json:"sequential,omitempty"` + Handlers []managedHookHandlerPayload `json:"handlers"` + Targets []string `json:"targets"` + SourceType string `json:"sourceType"` + Disabled bool `json:"disabled"` +} + +type managedHookPreview struct { + Target string `json:"target"` + Files []managedhooks.CompiledFile `json:"files"` + Warnings []string `json:"warnings,omitempty"` +} + +type managedHookRequest struct { + ID string `json:"id"` + Tool string `json:"tool"` + Event string `json:"event"` + Matcher *string `json:"matcher"` + Sequential *bool `json:"sequential"` + Handlers []managedHookHandlerPayload `json:"handlers"` + Targets *[]string `json:"targets"` + SourceType *string `json:"sourceType"` + Disabled *bool `json:"disabled"` +} + +func (s *Server) managedHooksProjectRoot() string { + if s.IsProjectMode() { + return s.projectRoot + } + return "" +} + +func (s *Server) handleListManagedHooks(w http.ResponseWriter, r *http.Request) { + store := managedhooks.NewStore(s.managedHooksProjectRoot()) + records, err := store.List() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list managed hooks: "+err.Error()) + return + } + + items := make([]managedHookPayload, 0, len(records)) + for _, record := range records { + items = append(items, managedHookRecordPayload(record)) + } + writeJSON(w, map[string]any{"hooks": items}) +} + +func (s *Server) handleCreateManagedHook(w http.ResponseWriter, r *http.Request) { + var body managedHookRequest + if err := decodeManagedHookRequest(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := managed.ValidateManagedHookSave(managed.HookInput{ + Tool: body.Tool, + }); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + store := managedhooks.NewStore(s.managedHooksProjectRoot()) + canonicalID, err := managedHookCanonicalID(body.Tool, body.Event, body.matcher()) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + s.mu.Lock() + if _, err := store.Get(canonicalID); err == nil { + s.mu.Unlock() + writeError(w, http.StatusConflict, "managed hook already exists: "+canonicalID) + return + } else if !managedHookNotFound(err) { + s.mu.Unlock() + writeError(w, managedHookLoadStatus(err), "failed to check managed hook: "+err.Error()) + return + } + + record, err := store.Put(managedHookSave(body, canonicalID, nil)) + if err != nil { + s.mu.Unlock() + writeError(w, managedHookSaveStatus(err), "failed to save managed hook: "+err.Error()) + return + } + + previews, err := s.loadManagedHookPreviews(store) + if err != nil { + rollbackErr := store.Delete(record.ID) + s.mu.Unlock() + writeManagedHookMutationPreviewError(w, err, rollbackErr, "failed to rollback created managed hook") + return + } + s.mu.Unlock() + + writeManagedHookDetailResponse(w, http.StatusCreated, record, previews) +} + +func (s *Server) handleGetManagedHook(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + writeError(w, http.StatusBadRequest, "hook id is required") + return + } + + record, status, err := s.loadManagedHook(id) + if err != nil { + writeError(w, status, err.Error()) + return + } + + s.writeManagedHookDetail(w, http.StatusOK, record) +} + +func (s *Server) handleUpdateManagedHook(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + writeError(w, http.StatusBadRequest, "hook id is required") + return + } + + var body managedHookRequest + if err := decodeManagedHookRequest(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := managed.ValidateManagedHookSave(managed.HookInput{ + Tool: body.Tool, + }); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + store := managedhooks.NewStore(s.managedHooksProjectRoot()) + s.mu.Lock() + existing, err := store.Get(id) + if err != nil { + s.mu.Unlock() + writeError(w, managedHookLoadStatus(err), managedHookLoadError(id, err).Error()) + return + } + + canonicalID, err := managedHookCanonicalID(body.Tool, body.Event, body.matcher()) + if err != nil { + s.mu.Unlock() + writeError(w, http.StatusBadRequest, err.Error()) + return + } + moved := canonicalID != existing.ID + if moved { + if _, err := store.Get(canonicalID); err == nil { + s.mu.Unlock() + writeError(w, http.StatusConflict, "managed hook already exists: "+canonicalID) + return + } else if !managedHookNotFound(err) { + s.mu.Unlock() + writeError(w, managedHookLoadStatus(err), "failed to check managed hook: "+err.Error()) + return + } + } + + record, err := store.Put(managedHookSave(body, canonicalID, &existing)) + if err != nil { + s.mu.Unlock() + writeError(w, managedHookSaveStatus(err), "failed to save managed hook: "+err.Error()) + return + } + + if moved { + if err := store.Delete(existing.ID); err != nil && !managedHookNotFound(err) { + _ = store.Delete(record.ID) + s.mu.Unlock() + writeError(w, http.StatusInternalServerError, "failed to rename managed hook: "+err.Error()) + return + } + } + + previews, err := s.loadManagedHookPreviews(store) + if err != nil { + if moved { + _ = store.Delete(record.ID) + } + rollbackErr := restoreManagedHookRecord(store, existing) + s.mu.Unlock() + writeManagedHookMutationPreviewError(w, err, rollbackErr, "failed to restore previous managed hook") + return + } + s.mu.Unlock() + + writeManagedHookDetailResponse(w, http.StatusOK, record, previews) +} + +func (s *Server) handleSetManagedHookTargets(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + writeError(w, http.StatusBadRequest, "hook id is required") + return + } + + var body managedTargetsRequest + if err := decodeStrictJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + store := managedhooks.NewStore(s.managedHooksProjectRoot()) + s.mu.Lock() + record, err := store.Get(id) + if err != nil { + s.mu.Unlock() + writeError(w, managedHookLoadStatus(err), managedHookLoadError(id, err).Error()) + return + } + if err := managed.ValidateManagedHookSave(managed.HookInput{ + Tool: record.Tool, + }); err != nil { + s.mu.Unlock() + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + record, err = store.Put(managedhooks.Save{ + ID: record.ID, + Tool: record.Tool, + Event: record.Event, + Matcher: record.Matcher, + Sequential: record.Sequential, + Handlers: append([]managedhooks.Handler(nil), record.Handlers...), + Targets: normalizeManagedTargets([]string{body.Target}), + SourceType: record.SourceType, + Disabled: record.Disabled, + }) + if err != nil { + s.mu.Unlock() + writeError(w, managedHookSaveStatus(err), "failed to save managed hook: "+err.Error()) + return + } + + previews, err := s.loadManagedHookPreviews(store) + s.mu.Unlock() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeManagedHookDetailResponse(w, http.StatusOK, record, previews) +} + +func (s *Server) handleSetManagedHookDisabled(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + writeError(w, http.StatusBadRequest, "hook id is required") + return + } + + var body managedDisabledRequest + if err := decodeStrictJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if body.Disabled == nil { + writeError(w, http.StatusBadRequest, "disabled is required") + return + } + + store := managedhooks.NewStore(s.managedHooksProjectRoot()) + s.mu.Lock() + record, err := store.Get(id) + if err != nil { + s.mu.Unlock() + writeError(w, managedHookLoadStatus(err), managedHookLoadError(id, err).Error()) + return + } + if err := managed.ValidateManagedHookSave(managed.HookInput{ + Tool: record.Tool, + }); err != nil { + s.mu.Unlock() + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + record, err = store.Put(managedhooks.Save{ + ID: record.ID, + Tool: record.Tool, + Event: record.Event, + Matcher: record.Matcher, + Sequential: record.Sequential, + Handlers: append([]managedhooks.Handler(nil), record.Handlers...), + Targets: append([]string(nil), record.Targets...), + SourceType: record.SourceType, + Disabled: *body.Disabled, + }) + if err != nil { + s.mu.Unlock() + writeError(w, managedHookSaveStatus(err), "failed to save managed hook: "+err.Error()) + return + } + + previews, err := s.loadManagedHookPreviews(store) + s.mu.Unlock() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeManagedHookDetailResponse(w, http.StatusOK, record, previews) +} + +func (s *Server) handleDeleteManagedHook(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + writeError(w, http.StatusBadRequest, "hook id is required") + return + } + + store := managedhooks.NewStore(s.managedHooksProjectRoot()) + if _, status, err := s.loadManagedHook(id); err != nil { + writeError(w, status, err.Error()) + return + } + + s.mu.Lock() + err := store.Delete(id) + s.mu.Unlock() + if err != nil { + status := http.StatusInternalServerError + if managedHookNotFound(err) { + status = http.StatusNotFound + } + writeError(w, status, "failed to delete managed hook: "+err.Error()) + return + } + + writeJSON(w, map[string]any{"success": true}) +} + +func (s *Server) handleCollectManagedHooks(w http.ResponseWriter, r *http.Request) { + var body struct { + GroupIDs []string `json:"groupIds"` + Strategy managedhooks.Strategy `json:"strategy"` + } + if err := decodeStrictJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if len(body.GroupIDs) == 0 { + writeError(w, http.StatusBadRequest, "at least one hook group id is required") + return + } + + projectRoot := s.managedHooksProjectRoot() + discovered, _, err := inspect.ScanHooks(projectRoot) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to scan hooks: "+err.Error()) + return + } + + discoveredByGroup := make(map[string][]inspect.HookItem) + for _, item := range discovered { + groupID := strings.TrimSpace(item.GroupID) + if groupID == "" { + continue + } + discoveredByGroup[groupID] = append(discoveredByGroup[groupID], item) + } + + selected := make([]inspect.HookItem, 0, len(discovered)) + seenGroupIDs := make(map[string]struct{}, len(body.GroupIDs)) + for _, rawGroupID := range body.GroupIDs { + groupID := strings.TrimSpace(rawGroupID) + if groupID == "" { + writeError(w, http.StatusBadRequest, "unknown discovered hook group id: "+rawGroupID) + return + } + if _, seen := seenGroupIDs[groupID]; seen { + continue + } + seenGroupIDs[groupID] = struct{}{} + + groupItems, ok := discoveredByGroup[groupID] + if !ok || len(groupItems) == 0 { + writeError(w, http.StatusBadRequest, "unknown discovered hook group id: "+groupID) + return + } + selected = append(selected, groupItems...) + } + + s.mu.Lock() + result, err := managed.CollectHooks(projectRoot, selected, body.Strategy) + s.mu.Unlock() + if err != nil { + status := http.StatusInternalServerError + if managedHookCollectInputError(err) { + status = http.StatusBadRequest + } + writeError(w, status, "failed to collect managed hooks: "+err.Error()) + return + } + + writeJSON(w, map[string]any{ + "created": result.Created, + "overwritten": result.Overwritten, + "skipped": result.Skipped, + }) +} + +func (s *Server) handleDiffManagedHooks(w http.ResponseWriter, r *http.Request) { + store := managedhooks.NewStore(s.managedHooksProjectRoot()) + records, err := store.List() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list managed hooks: "+err.Error()) + return + } + + previews, err := s.compileManagedHookPreviews(records) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to compile managed hooks diff: "+err.Error()) + return + } + + writeJSON(w, map[string]any{"diffs": previews}) +} + +func (s *Server) loadManagedHook(id string) (managedhooks.Record, int, error) { + record, err := managedhooks.NewStore(s.managedHooksProjectRoot()).Get(id) + if err != nil { + return managedhooks.Record{}, managedHookLoadStatus(err), managedHookLoadError(id, err) + } + return record, http.StatusOK, nil +} + +func (s *Server) writeManagedHookDetail(w http.ResponseWriter, status int, record managedhooks.Record) { + store := managedhooks.NewStore(s.managedHooksProjectRoot()) + previews, err := s.loadManagedHookPreviews(store) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeManagedHookDetailResponse(w, status, record, previews) +} + +func (s *Server) loadManagedHookPreviews(store *managedhooks.Store) ([]managedHookPreview, error) { + records, err := store.List() + if err != nil { + return nil, errors.New("failed to load managed hook previews: " + err.Error()) + } + + previews, err := s.compileManagedHookPreviews(records) + if err != nil { + return nil, errors.New("failed to compile managed hook previews: " + err.Error()) + } + return previews, nil +} + +func writeManagedHookDetailResponse(w http.ResponseWriter, status int, record managedhooks.Record, previews []managedHookPreview) { + writeJSONStatus(w, status, map[string]any{ + "hook": managedHookRecordPayload(record), + "previews": previews, + }) +} + +func restoreManagedHookRecord(store *managedhooks.Store, record managedhooks.Record) error { + _, err := store.Put(managedhooks.Save{ + ID: record.ID, + Tool: record.Tool, + Event: record.Event, + Matcher: record.Matcher, + Sequential: record.Sequential, + Handlers: record.Handlers, + Targets: append([]string(nil), record.Targets...), + SourceType: record.SourceType, + Disabled: record.Disabled, + }) + return err +} + +func writeManagedHookMutationPreviewError(w http.ResponseWriter, previewErr, rollbackErr error, rollbackPrefix string) { + if rollbackErr != nil { + writeError(w, http.StatusInternalServerError, previewErr.Error()+"; "+rollbackPrefix+": "+rollbackErr.Error()) + return + } + writeError(w, http.StatusInternalServerError, previewErr.Error()) +} + +func (s *Server) compileManagedHookPreviews(records []managedhooks.Record) ([]managedHookPreview, error) { + targetNames := make([]string, 0, len(s.cfg.Targets)) + for name := range s.cfg.Targets { + targetNames = append(targetNames, name) + } + sort.Strings(targetNames) + + previews := make([]managedHookPreview, 0, len(targetNames)) + projectRoot := "" + if s.IsProjectMode() { + projectRoot = s.projectRoot + } + for _, name := range targetNames { + target := s.cfg.Targets[name] + compileTarget, compileRoot, ok := managed.ResolveHookTarget(name, target, projectRoot) + if !ok { + previews = append(previews, managedHookPreview{ + Target: name, + Files: []managedhooks.CompiledFile{}, + Warnings: []string{"unsupported target \"" + name + "\""}, + }) + continue + } + + rawConfig, err := managed.LoadHookRawConfig(compileTarget, compileRoot) + if err != nil { + return nil, err + } + files, warnings, err := managedhooks.CompileTarget(records, compileTarget, name, compileRoot, string(rawConfig)) + if err != nil { + return nil, err + } + if files == nil { + files = []managedhooks.CompiledFile{} + } + previews = append(previews, managedHookPreview{ + Target: name, + Files: files, + Warnings: warnings, + }) + } + return previews, nil +} + +func decodeManagedHookRequest(r *http.Request, body *managedHookRequest) error { + if err := decodeStrictJSON(r, body); err != nil { + return errors.New("invalid request body: " + err.Error()) + } + + body.ID = strings.TrimSpace(body.ID) + body.Tool = strings.TrimSpace(body.Tool) + body.Event = strings.TrimSpace(body.Event) + if body.Tool == "" { + return errors.New("tool is required") + } + if body.Event == "" { + return errors.New("event is required") + } + if body.Matcher == nil && !managedHookAllowsEmptyMatcher(body.Tool, body.Event) { + return errors.New("matcher is required") + } + if len(body.Handlers) == 0 { + return errors.New("handlers are required") + } + if body.Matcher != nil { + m := strings.TrimSpace(*body.Matcher) + body.Matcher = &m + } + if body.SourceType != nil { + sourceType := strings.TrimSpace(*body.SourceType) + body.SourceType = &sourceType + } + if !managedHookAllowsEmptyMatcher(body.Tool, body.Event) { + if body.matcher() == "" { + return errors.New("matcher is required") + } + } + return nil +} + +func managedHookAllowsEmptyMatcher(tool, event string) bool { + normalizedTool := strings.ToLower(strings.TrimSpace(tool)) + normalizedEvent := strings.TrimSpace(event) + if normalizedTool == "codex" && (normalizedEvent == "UserPromptSubmit" || normalizedEvent == "Stop") { + return true + } + return normalizedTool == "gemini" +} + +func (r managedHookRequest) matcher() string { + if r.Matcher == nil { + return "" + } + return strings.TrimSpace(*r.Matcher) +} + +func (r managedHookRequest) toHandlers() []managedhooks.Handler { + if len(r.Handlers) == 0 { + return nil + } + + out := make([]managedhooks.Handler, len(r.Handlers)) + for i, handler := range r.Handlers { + out[i] = managedhooks.Handler{ + Type: strings.TrimSpace(handler.Type), + Name: strings.TrimSpace(handler.Name), + Description: strings.TrimSpace(handler.Description), + Command: strings.TrimSpace(handler.Command), + URL: strings.TrimSpace(handler.URL), + Prompt: strings.TrimSpace(handler.Prompt), + Timeout: strings.TrimSpace(handler.Timeout), + TimeoutSeconds: handler.TimeoutSeconds, + StatusMessage: strings.TrimSpace(handler.StatusMessage), + } + } + return out +} + +func managedHookCanonicalID(tool, event, matcher string) (string, error) { + return managedhooks.CanonicalRelativePath(tool, event, matcher) +} + +func managedHookRecordPayload(record managedhooks.Record) managedHookPayload { + handlers := make([]managedHookHandlerPayload, len(record.Handlers)) + for i, handler := range record.Handlers { + handlers[i] = managedHookHandlerPayload{ + Type: handler.Type, + Name: handler.Name, + Description: handler.Description, + Command: handler.Command, + URL: handler.URL, + Prompt: handler.Prompt, + Timeout: handler.Timeout, + TimeoutSeconds: handler.TimeoutSeconds, + StatusMessage: handler.StatusMessage, + } + } + return managedHookPayload{ + ID: record.ID, + Tool: record.Tool, + Event: record.Event, + Matcher: record.Matcher, + Sequential: record.Sequential, + Handlers: handlers, + Targets: append([]string(nil), record.Targets...), + SourceType: record.SourceType, + Disabled: record.Disabled, + } +} + +func managedHookSave(body managedHookRequest, id string, existing *managedhooks.Record) managedhooks.Save { + sourceType := "local" + disabled := false + var targets []string + if existing != nil { + sourceType = existing.SourceType + disabled = existing.Disabled + targets = append([]string(nil), existing.Targets...) + } + if body.Targets != nil { + targets = normalizeManagedTargets(*body.Targets) + } + if body.SourceType != nil { + sourceType = strings.TrimSpace(*body.SourceType) + } + if sourceType == "" { + sourceType = "local" + } + if body.Disabled != nil { + disabled = *body.Disabled + } + return managedhooks.Save{ + ID: id, + Tool: body.Tool, + Event: body.Event, + Matcher: body.matcher(), + Sequential: body.Sequential, + Handlers: body.toHandlers(), + Targets: targets, + SourceType: sourceType, + Disabled: disabled, + } +} + +func managedHookNotFound(err error) bool { + return errors.Is(err, os.ErrNotExist) +} + +func managedHookInvalidID(err error) bool { + return strings.Contains(strings.ToLower(strings.TrimSpace(err.Error())), "invalid hook id") +} + +func managedHookLoadStatus(err error) int { + switch { + case managedHookInvalidID(err): + return http.StatusBadRequest + case managedHookNotFound(err): + return http.StatusNotFound + default: + return http.StatusInternalServerError + } +} + +func managedHookSaveStatus(err error) int { + if managedHookValidationError(err) { + return http.StatusBadRequest + } + return http.StatusInternalServerError +} + +func managedHookValidationError(err error) bool { + if managedHookInvalidID(err) { + return true + } + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(err.Error())), "hook \"") +} + +func managedHookLoadError(id string, err error) error { + if managedHookNotFound(err) { + return errors.New("managed hook not found: " + id) + } + return err +} + +func managedHookCollectInputError(err error) bool { + msg := strings.TrimSpace(strings.ToLower(err.Error())) + return strings.Contains(msg, "invalid collect strategy") || + strings.Contains(msg, "cannot collect ") +} diff --git a/internal/server/handler_managed_hooks_test.go b/internal/server/handler_managed_hooks_test.go new file mode 100644 index 00000000..2bb5f244 --- /dev/null +++ b/internal/server/handler_managed_hooks_test.go @@ -0,0 +1,835 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "skillshare/internal/config" + "skillshare/internal/inspect" + managedhooks "skillshare/internal/resources/hooks" +) + +func canonicalManagedHookID(t *testing.T, tool, event, matcher string) string { + t.Helper() + id, err := managedhooks.CanonicalRelativePath(tool, event, matcher) + if err != nil { + t.Fatalf("failed to derive canonical managed hook id: %v", err) + } + return id +} + +func TestManagedHooksCRUDAndDiff(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude") + hookID := canonicalManagedHookID(t, "claude", "PreToolUse", "Bash") + + createBody := `{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check","statusMessage":"Checking"}]}` + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(createBody)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + var createResp struct { + Hook struct { + ID string `json:"id"` + Event string `json:"event"` + Matcher string `json:"matcher"` + } `json:"hook"` + Previews []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + } `json:"files"` + Warnings []string `json:"warnings"` + } `json:"previews"` + } + if err := json.Unmarshal(createRR.Body.Bytes(), &createResp); err != nil { + t.Fatalf("failed to decode create response: %v", err) + } + if createResp.Hook.ID != hookID { + t.Fatalf("create response hook id = %q, want %q", createResp.Hook.ID, hookID) + } + if len(createResp.Previews) != 1 || createResp.Previews[0].Target != "claude" { + t.Fatalf("create previews = %#v, want one claude preview", createResp.Previews) + } + if len(createResp.Previews[0].Warnings) != 0 { + t.Fatalf("create preview warnings = %#v, want none", createResp.Previews[0].Warnings) + } + if len(createResp.Previews[0].Files) != 1 || createResp.Previews[0].Files[0].Path != filepath.Join(projectRoot, ".claude", "settings.json") { + t.Fatalf("create preview files = %#v, want compiled claude settings path under project root", createResp.Previews[0].Files) + } + + dupReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(createBody)) + dupRR := httptest.NewRecorder() + s.handler.ServeHTTP(dupRR, dupReq) + if dupRR.Code != http.StatusConflict { + t.Fatalf("expected 409 from duplicate create, got %d: %s", dupRR.Code, dupRR.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/managed/hooks/"+hookID, nil) + getRR := httptest.NewRecorder() + s.handler.ServeHTTP(getRR, getReq) + if getRR.Code != http.StatusOK { + t.Fatalf("expected 200 from get, got %d: %s", getRR.Code, getRR.Body.String()) + } + + var getResp struct { + Hook struct { + ID string `json:"id"` + Event string `json:"event"` + Matcher string `json:"matcher"` + Handlers []struct { + Type string `json:"type"` + Command string `json:"command"` + } `json:"handlers"` + } `json:"hook"` + } + if err := json.Unmarshal(getRR.Body.Bytes(), &getResp); err != nil { + t.Fatalf("failed to decode get response: %v", err) + } + if getResp.Hook.ID != hookID || getResp.Hook.Event != "PreToolUse" || getResp.Hook.Matcher != "Bash" { + t.Fatalf("get hook = %#v, want id/event/matcher round-trip", getResp.Hook) + } + if len(getResp.Hook.Handlers) != 1 || getResp.Hook.Handlers[0].Command != "./bin/check" { + t.Fatalf("get handlers = %#v, want command handler", getResp.Hook.Handlers) + } + + updateBody := `{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/updated","statusMessage":"Updated"}]}` + updateReq := httptest.NewRequest(http.MethodPut, "/api/managed/hooks/"+hookID, strings.NewReader(updateBody)) + updateRR := httptest.NewRecorder() + s.handler.ServeHTTP(updateRR, updateReq) + if updateRR.Code != http.StatusOK { + t.Fatalf("expected 200 from update, got %d: %s", updateRR.Code, updateRR.Body.String()) + } + + diffReq := httptest.NewRequest(http.MethodGet, "/api/managed/hooks/diff", nil) + diffRR := httptest.NewRecorder() + s.handler.ServeHTTP(diffRR, diffReq) + if diffRR.Code != http.StatusOK { + t.Fatalf("expected 200 from diff, got %d: %s", diffRR.Code, diffRR.Body.String()) + } + + var diffResp struct { + Diffs []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + Content string `json:"content"` + Format string `json:"format"` + } `json:"files"` + } `json:"diffs"` + } + if err := json.Unmarshal(diffRR.Body.Bytes(), &diffResp); err != nil { + t.Fatalf("failed to decode diff response: %v", err) + } + if len(diffResp.Diffs) != 1 || diffResp.Diffs[0].Target != "claude" { + t.Fatalf("diff response = %#v, want one claude diff", diffResp.Diffs) + } + if len(diffResp.Diffs[0].Files) != 1 || diffResp.Diffs[0].Files[0].Path != filepath.Join(projectRoot, ".claude", "settings.json") { + t.Fatalf("diff files = %#v, want canonical claude settings path under project root", diffResp.Diffs[0].Files) + } + if !strings.Contains(diffResp.Diffs[0].Files[0].Content, "./bin/updated") { + t.Fatalf("diff content = %q, want updated command", diffResp.Diffs[0].Files[0].Content) + } + + renameID, err := managedhooks.CanonicalRelativePath("claude", "PreToolUse", "Write") + if err != nil { + t.Fatalf("failed to derive renamed hook id: %v", err) + } + renameReq := httptest.NewRequest(http.MethodPut, "/api/managed/hooks/"+hookID, strings.NewReader(`{"tool":"claude","event":"PreToolUse","matcher":"Write","handlers":[{"type":"command","command":"./bin/renamed","statusMessage":"Updated"}]}`)) + renameRR := httptest.NewRecorder() + s.handler.ServeHTTP(renameRR, renameReq) + if renameRR.Code != http.StatusOK { + t.Fatalf("expected 200 from rename update, got %d: %s", renameRR.Code, renameRR.Body.String()) + } + + var renameResp struct { + Hook struct { + ID string `json:"id"` + Matcher string `json:"matcher"` + } `json:"hook"` + } + if err := json.Unmarshal(renameRR.Body.Bytes(), &renameResp); err != nil { + t.Fatalf("failed to decode rename response: %v", err) + } + if renameResp.Hook.ID != renameID || renameResp.Hook.Matcher != "Write" { + t.Fatalf("rename response hook = %#v, want id %q and matcher Write", renameResp.Hook, renameID) + } + if _, err := os.Stat(filepath.Join(projectRoot, ".skillshare", "hooks", filepath.FromSlash(renameID))); err != nil { + t.Fatalf("expected renamed managed hook file %s: %v", renameID, err) + } + if _, err := os.Stat(filepath.Join(projectRoot, ".skillshare", "hooks", "claude", "pre-tool-use", "bash.yaml")); !os.IsNotExist(err) { + t.Fatalf("expected old managed hook file to be removed, got err=%v", err) + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/api/managed/hooks/"+renameID, nil) + deleteRR := httptest.NewRecorder() + s.handler.ServeHTTP(deleteRR, deleteReq) + if deleteRR.Code != http.StatusOK { + t.Fatalf("expected 200 from delete, got %d: %s", deleteRR.Code, deleteRR.Body.String()) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/managed/hooks", nil) + listRR := httptest.NewRecorder() + s.handler.ServeHTTP(listRR, listReq) + if listRR.Code != http.StatusOK { + t.Fatalf("expected 200 from list, got %d: %s", listRR.Code, listRR.Body.String()) + } + var listResp struct { + Hooks []struct { + ID string `json:"id"` + } `json:"hooks"` + } + if err := json.Unmarshal(listRR.Body.Bytes(), &listResp); err != nil { + t.Fatalf("failed to decode list response: %v", err) + } + if len(listResp.Hooks) != 0 { + t.Fatalf("expected 0 hooks after delete, got %d", len(listResp.Hooks)) + } +} + +func TestHandleManagedHooks_CreateAndUpdateExposeMetadata(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + hookID := canonicalManagedHookID(t, "claude", "PreToolUse", "Bash") + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(`{"tool":"claude","event":"PreToolUse","matcher":"Bash","targets":["claude-work"],"sourceType":"tracked","disabled":true,"handlers":[{"type":"command","command":"./bin/check"}]}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + var createResp struct { + Hook struct { + Targets []string `json:"targets"` + SourceType string `json:"sourceType"` + Disabled bool `json:"disabled"` + } `json:"hook"` + } + if err := json.Unmarshal(createRR.Body.Bytes(), &createResp); err != nil { + t.Fatalf("failed to decode create response: %v", err) + } + if len(createResp.Hook.Targets) != 1 || createResp.Hook.Targets[0] != "claude-work" { + t.Fatalf("create targets = %v, want [claude-work]", createResp.Hook.Targets) + } + if createResp.Hook.SourceType != "tracked" { + t.Fatalf("create sourceType = %q, want %q", createResp.Hook.SourceType, "tracked") + } + if !createResp.Hook.Disabled { + t.Fatalf("create disabled = %v, want true", createResp.Hook.Disabled) + } + + updateReq := httptest.NewRequest(http.MethodPut, "/api/managed/hooks/"+url.PathEscape(hookID), strings.NewReader(`{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/updated"}]}`)) + updateRR := httptest.NewRecorder() + s.handler.ServeHTTP(updateRR, updateReq) + if updateRR.Code != http.StatusOK { + t.Fatalf("expected 200 from update, got %d: %s", updateRR.Code, updateRR.Body.String()) + } + + var updateResp struct { + Hook struct { + Targets []string `json:"targets"` + SourceType string `json:"sourceType"` + Disabled bool `json:"disabled"` + } `json:"hook"` + } + if err := json.Unmarshal(updateRR.Body.Bytes(), &updateResp); err != nil { + t.Fatalf("failed to decode update response: %v", err) + } + if len(updateResp.Hook.Targets) != 1 || updateResp.Hook.Targets[0] != "claude-work" { + t.Fatalf("update targets = %v, want [claude-work]", updateResp.Hook.Targets) + } + if updateResp.Hook.SourceType != "tracked" { + t.Fatalf("update sourceType = %q, want %q", updateResp.Hook.SourceType, "tracked") + } + if !updateResp.Hook.Disabled { + t.Fatalf("update disabled = %v, want true", updateResp.Hook.Disabled) + } +} + +func TestHandleManagedHookDisabled_PersistsDisabledState(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude") + store := managedhooks.NewStore(projectRoot) + hookID := canonicalManagedHookID(t, "claude", "PreToolUse", "Bash") + if _, err := store.Put(managedhooks.Save{ + ID: hookID, + Tool: "claude", + Event: "PreToolUse", + Matcher: "Bash", + Handlers: []managedhooks.Handler{{ + Type: "command", + Command: "./bin/check", + }}, + }); err != nil { + t.Fatalf("put hook: %v", err) + } + + req := httptest.NewRequest(http.MethodPatch, "/api/managed/hooks/"+url.PathEscape(hookID)+"/disabled", strings.NewReader(`{"disabled":true}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rr.Code, http.StatusOK, rr.Body.String()) + } + got, err := store.Get(hookID) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if !got.Disabled { + t.Fatal("expected hook to be disabled") + } +} + +func TestManagedHooksGeminiCreateGetAndDiffPreserveMetadata(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "gemini") + targetPath := filepath.Join(projectRoot, ".gemini", "skills") + if err := os.MkdirAll(targetPath, 0o755); err != nil { + t.Fatalf("failed to create gemini target dir: %v", err) + } + s.cfg.Targets["gemini"] = config.TargetConfig{Path: targetPath} + + createBody := `{"tool":"gemini","event":"BeforeTool","matcher":"Read","sequential":true,"handlers":[{"type":"command","name":"lint-read","description":"Run read lint","command":"./bin/gemini-lint","timeout":"30000"}]}` + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(createBody)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from gemini create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + var createResp struct { + Hook struct { + ID string `json:"id"` + Sequential *bool `json:"sequential"` + Handlers []struct { + Name string `json:"name"` + Description string `json:"description"` + Timeout string `json:"timeout"` + } `json:"handlers"` + } `json:"hook"` + Previews []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + Content string `json:"content"` + } `json:"files"` + } `json:"previews"` + } + if err := json.Unmarshal(createRR.Body.Bytes(), &createResp); err != nil { + t.Fatalf("failed to decode gemini create response: %v", err) + } + if createResp.Hook.Sequential == nil || !*createResp.Hook.Sequential { + t.Fatalf("create sequential = %#v, want true", createResp.Hook.Sequential) + } + if len(createResp.Hook.Handlers) != 1 { + t.Fatalf("create handlers = %#v, want one handler", createResp.Hook.Handlers) + } + if createResp.Hook.Handlers[0].Name != "lint-read" || createResp.Hook.Handlers[0].Description != "Run read lint" || createResp.Hook.Handlers[0].Timeout != "30000" { + t.Fatalf("create gemini handler metadata = %#v, want preserved metadata", createResp.Hook.Handlers[0]) + } + if len(createResp.Previews) != 1 || createResp.Previews[0].Target != "gemini" { + t.Fatalf("create previews = %#v, want one gemini preview", createResp.Previews) + } + if len(createResp.Previews[0].Files) != 1 || createResp.Previews[0].Files[0].Path != filepath.Join(projectRoot, ".gemini", "settings.json") { + t.Fatalf("create preview files = %#v, want gemini settings path", createResp.Previews[0].Files) + } + for _, want := range []string{`"sequential":true`, `"name":"lint-read"`, `"description":"Run read lint"`} { + if !strings.Contains(createResp.Previews[0].Files[0].Content, want) { + t.Fatalf("gemini preview content missing %q: %q", want, createResp.Previews[0].Files[0].Content) + } + } + + hookID := canonicalManagedHookID(t, "gemini", "BeforeTool", "Read") + getReq := httptest.NewRequest(http.MethodGet, "/api/managed/hooks/"+hookID, nil) + getRR := httptest.NewRecorder() + s.handler.ServeHTTP(getRR, getReq) + if getRR.Code != http.StatusOK { + t.Fatalf("expected 200 from gemini get, got %d: %s", getRR.Code, getRR.Body.String()) + } + + diffReq := httptest.NewRequest(http.MethodGet, "/api/managed/hooks/diff", nil) + diffRR := httptest.NewRecorder() + s.handler.ServeHTTP(diffRR, diffReq) + if diffRR.Code != http.StatusOK { + t.Fatalf("expected 200 from gemini diff, got %d: %s", diffRR.Code, diffRR.Body.String()) + } +} + +func TestManagedHooksCreateRejectsGeminiInvalidTimeoutEvenWhenTimeoutSecPresent(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "gemini") + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(`{"tool":"gemini","event":"BeforeTool","matcher":"Read","handlers":[{"type":"command","command":"./bin/check","timeout":"30s","timeoutSec":30000}]}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from gemini create with invalid timeout, got %d: %s", createRR.Code, createRR.Body.String()) + } + if !strings.Contains(createRR.Body.String(), "timeout must be numeric milliseconds for gemini") { + t.Fatalf("create error = %q, want gemini timeout validation message", createRR.Body.String()) + } +} + +func TestManagedHooksGeminiCreateSupportsEmptyMatcher(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "gemini") + targetPath := filepath.Join(projectRoot, ".gemini", "skills") + if err := os.MkdirAll(targetPath, 0o755); err != nil { + t.Fatalf("failed to create gemini target dir: %v", err) + } + s.cfg.Targets["gemini"] = config.TargetConfig{Path: targetPath} + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(`{"tool":"gemini","event":"Notification","handlers":[{"type":"command","command":"./bin/notify"}]}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from matcherless gemini create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + var createResp struct { + Hook struct { + ID string `json:"id"` + Matcher string `json:"matcher"` + } `json:"hook"` + Previews []struct { + Target string `json:"target"` + Files []struct { + Content string `json:"content"` + } `json:"files"` + } `json:"previews"` + } + if err := json.Unmarshal(createRR.Body.Bytes(), &createResp); err != nil { + t.Fatalf("failed to decode matcherless gemini create response: %v", err) + } + wantID := canonicalManagedHookID(t, "gemini", "Notification", "") + if createResp.Hook.ID != wantID { + t.Fatalf("create hook id = %q, want %q", createResp.Hook.ID, wantID) + } + if createResp.Hook.Matcher != "" { + t.Fatalf("create matcher = %q, want empty", createResp.Hook.Matcher) + } + if len(createResp.Previews) != 1 || len(createResp.Previews[0].Files) != 1 { + t.Fatalf("create previews = %#v, want one gemini preview file", createResp.Previews) + } + if strings.Contains(createResp.Previews[0].Files[0].Content, `"matcher"`) { + t.Fatalf("matcherless gemini preview should omit matcher field: %q", createResp.Previews[0].Files[0].Content) + } +} + +func TestManagedHooksCollectRoute(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude") + + discoveredPath := filepath.Join(projectRoot, ".claude", "settings.json") + if err := os.MkdirAll(filepath.Dir(discoveredPath), 0755); err != nil { + t.Fatalf("failed to create discovered hook dir: %v", err) + } + if err := os.WriteFile(discoveredPath, []byte(`{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./bin/check"}]}],"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./bin/post"}]}]}}`), 0644); err != nil { + t.Fatalf("failed to write discovered hook config: %v", err) + } + + discovered, _, err := inspect.ScanHooks(projectRoot) + if err != nil { + t.Fatalf("ScanHooks() error = %v", err) + } + + var preGroupID, postGroupID string + for _, item := range discovered { + if item.Path != discoveredPath { + continue + } + if item.Event == "PreToolUse" { + preGroupID = item.GroupID + } + if item.Event == "PostToolUse" { + postGroupID = item.GroupID + } + } + if preGroupID == "" || postGroupID == "" { + t.Fatalf("failed to find discovered hook groups for %s (pre=%q post=%q)", discoveredPath, preGroupID, postGroupID) + } + + collectReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks/collect", strings.NewReader(`{"groupIds":["`+preGroupID+`","`+preGroupID+`","`+postGroupID+`"],"strategy":"overwrite"}`)) + collectRR := httptest.NewRecorder() + s.handler.ServeHTTP(collectRR, collectReq) + if collectRR.Code != http.StatusOK { + t.Fatalf("expected 200 from collect, got %d: %s", collectRR.Code, collectRR.Body.String()) + } + + var collectResp struct { + Created []string `json:"created"` + Overwritten []string `json:"overwritten"` + Skipped []string `json:"skipped"` + } + if err := json.Unmarshal(collectRR.Body.Bytes(), &collectResp); err != nil { + t.Fatalf("failed to decode collect response: %v", err) + } + if len(collectResp.Created) != 2 { + t.Fatalf("collect created = %#v, want exactly two created managed hooks after dedupe", collectResp.Created) + } + if !strings.Contains(collectResp.Created[0], "/pre-tool-use/") || !strings.Contains(collectResp.Created[1], "/post-tool-use/") { + t.Fatalf("collect created order = %#v, want first-seen group order", collectResp.Created) + } + if len(collectResp.Overwritten) != 0 { + t.Fatalf("collect overwritten = %#v, want none", collectResp.Overwritten) + } + if len(collectResp.Skipped) != 0 { + t.Fatalf("collect skipped = %#v, want none", collectResp.Skipped) + } + + for _, managedID := range collectResp.Created { + managedPath := filepath.Join(projectRoot, ".skillshare", "hooks", filepath.FromSlash(managedID)) + if _, err := os.Stat(managedPath); err != nil { + t.Fatalf("expected managed hook file at %s: %v", managedPath, err) + } + } + + for name, body := range map[string]string{ + "unknown group id": `{"groupIds":["unknown-group"],"strategy":"overwrite"}`, + "unknown field": `{"groupIds":["` + preGroupID + `"],"strategy":"overwrite","extra":true}`, + "trailing json": `{"groupIds":["` + preGroupID + `"],"strategy":"overwrite"}{"extra":true}`, + "missing group ids": `{"strategy":"overwrite"}`, + "invalid strategy": `{"groupIds":["` + preGroupID + `"],"strategy":"invalid"}`, + } { + t.Run(name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/managed/hooks/collect", strings.NewReader(body)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from collect, got %d: %s", rr.Code, rr.Body.String()) + } + }) + } +} + +func TestManagedHooksUpdateUsesPathIDAndExistingRecord(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + hookID := canonicalManagedHookID(t, "claude", "PreToolUse", "Bash") + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(`{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}]}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + updateReq := httptest.NewRequest(http.MethodPut, "/api/managed/hooks/"+hookID, strings.NewReader(`{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}]}`)) + updateRR := httptest.NewRecorder() + s.handler.ServeHTTP(updateRR, updateReq) + if updateRR.Code != http.StatusOK { + t.Fatalf("expected 200 from update without body id, got %d: %s", updateRR.Code, updateRR.Body.String()) + } + + missingReq := httptest.NewRequest(http.MethodPut, "/api/managed/hooks/claude/pre-tool-use/missing.yaml", strings.NewReader(`{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}]}`)) + missingRR := httptest.NewRecorder() + s.handler.ServeHTTP(missingRR, missingReq) + if missingRR.Code != http.StatusNotFound { + t.Fatalf("expected 404 from update missing hook, got %d: %s", missingRR.Code, missingRR.Body.String()) + } +} + +func TestManagedHooksCreateAndUpdateRequireFieldsAndStrictJSON(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + hookID := canonicalManagedHookID(t, "claude", "PreToolUse", "Bash") + + for name, body := range map[string]string{ + "missing tool": `{"event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}]}`, + "missing event": `{"tool":"claude","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}]}`, + "missing matcher": `{"tool":"claude","event":"PreToolUse","handlers":[{"type":"command","command":"./bin/check"}]}`, + "missing handlers": `{"tool":"claude","event":"PreToolUse","matcher":"Bash"}`, + "unknown field": `{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}],"extra":true}`, + "trailing json": `{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}]}{"extra":true}`, + } { + t.Run("create "+name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(body)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from create, got %d: %s", rr.Code, rr.Body.String()) + } + }) + } + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(`{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}]}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + for name, body := range map[string]string{ + "missing handlers": `{"tool":"claude","event":"PreToolUse","matcher":"Bash"}`, + "unknown field": `{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}],"extra":true}`, + "trailing json": `{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}]}{"extra":true}`, + } { + t.Run("update "+name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPut, "/api/managed/hooks/"+hookID, strings.NewReader(body)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from update, got %d: %s", rr.Code, rr.Body.String()) + } + }) + } +} + +func TestManagedHooksCreateAndUpdateRejectStoreValidationErrorsAsBadRequest(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + hookID := canonicalManagedHookID(t, "claude", "PreToolUse", "Bash") + + for name, body := range map[string]string{ + "unsupported tool": `{"tool":"cursor","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}]}`, + "missing nested command": `{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command"}]}`, + "missing nested prompt": `{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"prompt"}]}`, + "missing nested webhook": `{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"http"}]}`, + "unsupported handler type": `{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"unknown","command":"./bin/check"}]}`, + } { + t.Run("create "+name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(body)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from create, got %d: %s", rr.Code, rr.Body.String()) + } + }) + } + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(`{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}]}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + for name, body := range map[string]string{ + "unsupported tool": `{"tool":"cursor","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}]}`, + "missing nested command": `{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command"}]}`, + "missing nested prompt": `{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"prompt"}]}`, + "missing nested webhook": `{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"http"}]}`, + "unsupported handler type": `{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"unknown","command":"./bin/check"}]}`, + } { + t.Run("update "+name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPut, "/api/managed/hooks/"+hookID, strings.NewReader(body)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from update, got %d: %s", rr.Code, rr.Body.String()) + } + }) + } +} + +func TestManagedHooksDiffUsesCanonicalProjectRootsForAliasAndSharedTargets(t *testing.T) { + t.Run("claude alias target", func(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude-code") + + targetPath := filepath.Join(projectRoot, ".claude", "skills") + if err := os.MkdirAll(targetPath, 0755); err != nil { + t.Fatalf("failed to create target dir: %v", err) + } + s.cfg.Targets["claude-code"] = config.TargetConfig{Path: targetPath} + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(`{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}]}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + diffReq := httptest.NewRequest(http.MethodGet, "/api/managed/hooks/diff", nil) + diffRR := httptest.NewRecorder() + s.handler.ServeHTTP(diffRR, diffReq) + if diffRR.Code != http.StatusOK { + t.Fatalf("expected 200 from diff, got %d: %s", diffRR.Code, diffRR.Body.String()) + } + + var diffResp struct { + Diffs []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + } `json:"files"` + Warnings []string `json:"warnings"` + } `json:"diffs"` + } + if err := json.Unmarshal(diffRR.Body.Bytes(), &diffResp); err != nil { + t.Fatalf("failed to decode diff response: %v", err) + } + if len(diffResp.Diffs) != 1 || diffResp.Diffs[0].Target != "claude-code" { + t.Fatalf("diff response = %#v, want one claude-code diff", diffResp.Diffs) + } + if len(diffResp.Diffs[0].Warnings) != 0 { + t.Fatalf("diff warnings = %#v, want none", diffResp.Diffs[0].Warnings) + } + if len(diffResp.Diffs[0].Files) != 1 || diffResp.Diffs[0].Files[0].Path != filepath.Join(projectRoot, ".claude", "settings.json") { + t.Fatalf("diff files = %#v, want canonical claude settings path under project root", diffResp.Diffs[0].Files) + } + }) + + t.Run("codex shared target", func(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "universal") + + targetPath := filepath.Join(projectRoot, ".agents", "skills") + if err := os.MkdirAll(targetPath, 0755); err != nil { + t.Fatalf("failed to create target dir: %v", err) + } + s.cfg.Targets["universal"] = config.TargetConfig{Path: targetPath} + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(`{"tool":"codex","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check","timeoutSec":30}]}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + var createResp struct { + Previews []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + } `json:"files"` + Warnings []string `json:"warnings"` + } `json:"previews"` + } + if err := json.Unmarshal(createRR.Body.Bytes(), &createResp); err != nil { + t.Fatalf("failed to decode create response: %v", err) + } + if len(createResp.Previews) != 1 || createResp.Previews[0].Target != "universal" { + t.Fatalf("create previews = %#v, want one universal preview", createResp.Previews) + } + if len(createResp.Previews[0].Warnings) != 0 { + t.Fatalf("create preview warnings = %#v, want none", createResp.Previews[0].Warnings) + } + if len(createResp.Previews[0].Files) != 2 { + t.Fatalf("create preview files = %#v, want codex config + hooks outputs", createResp.Previews[0].Files) + } + wantPaths := map[string]bool{ + filepath.Join(projectRoot, ".codex", "config.toml"): false, + filepath.Join(projectRoot, ".codex", "hooks.json"): false, + } + for _, file := range createResp.Previews[0].Files { + _, ok := wantPaths[file.Path] + if !ok { + t.Fatalf("unexpected codex preview path %q in %#v", file.Path, createResp.Previews[0].Files) + } + wantPaths[file.Path] = true + } + for path, seen := range wantPaths { + if !seen { + t.Fatalf("missing codex preview path %q in %#v", path, createResp.Previews[0].Files) + } + } + }) +} + +func TestManagedHooksUnsupportedTargetPreviewWarning(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "cursor") + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(`{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}]}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + var createResp struct { + Previews []struct { + Target string `json:"target"` + Files []struct{} `json:"files"` + Warnings []string `json:"warnings"` + } `json:"previews"` + } + if err := json.Unmarshal(createRR.Body.Bytes(), &createResp); err != nil { + t.Fatalf("failed to decode create response: %v", err) + } + if len(createResp.Previews) != 1 || createResp.Previews[0].Target != "cursor" { + t.Fatalf("create previews = %#v, want one cursor preview", createResp.Previews) + } + if len(createResp.Previews[0].Files) != 0 { + t.Fatalf("create preview files = %#v, want empty files for unsupported target", createResp.Previews[0].Files) + } + if len(createResp.Previews[0].Warnings) == 0 { + t.Fatalf("create preview warnings = %#v, want unsupported-target warning", createResp.Previews[0].Warnings) + } +} + +func TestManagedHooksCreateRollsBackWhenPreviewCompilationFails(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude") + + previewConfigPath := filepath.Join(projectRoot, ".claude", "settings.json") + if err := os.MkdirAll(previewConfigPath, 0755); err != nil { + t.Fatalf("failed to create preview failure path: %v", err) + } + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(`{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/check"}]}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 from create when preview compilation fails, got %d: %s", createRR.Code, createRR.Body.String()) + } + + records, err := managedhooks.NewStore(projectRoot).List() + if err != nil { + t.Fatalf("failed to list managed hooks after create failure: %v", err) + } + if len(records) != 0 { + t.Fatalf("managed hook create was not rolled back; got records %#v", records) + } +} + +func TestManagedHooksUpdateRestoresPreviousRecordWhenPreviewCompilationFails(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude") + + hookID := canonicalManagedHookID(t, "claude", "PreToolUse", "Bash") + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(`{"tool":"claude","event":"PreToolUse","matcher":"Bash","handlers":[{"type":"command","command":"./bin/original"}]}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + previewConfigPath := filepath.Join(projectRoot, ".claude", "settings.json") + if err := os.MkdirAll(previewConfigPath, 0755); err != nil { + t.Fatalf("failed to create preview failure path: %v", err) + } + + updateReq := httptest.NewRequest(http.MethodPut, "/api/managed/hooks/"+hookID, strings.NewReader(`{"tool":"claude","event":"PreToolUse","matcher":"Write","handlers":[{"type":"command","command":"./bin/updated"}]}`)) + updateRR := httptest.NewRecorder() + s.handler.ServeHTTP(updateRR, updateReq) + if updateRR.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 from update when preview compilation fails, got %d: %s", updateRR.Code, updateRR.Body.String()) + } + + renamedID, err := managedhooks.CanonicalRelativePath("claude", "PreToolUse", "Write") + if err != nil { + t.Fatalf("failed to derive renamed hook id: %v", err) + } + record, err := managedhooks.NewStore(projectRoot).Get(hookID) + if err != nil { + t.Fatalf("failed to load hook after failed update: %v", err) + } + if len(record.Handlers) != 1 || record.Handlers[0].Command != "./bin/original" { + t.Fatalf("managed hook update was not rolled back; got handlers %#v", record.Handlers) + } + if _, err := managedhooks.NewStore(projectRoot).Get(renamedID); err == nil { + t.Fatalf("expected renamed hook %s to be rolled back", renamedID) + } +} + +func TestManagedHooksRejectUnsupportedFamilyBeforeStoreWrite(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + req := httptest.NewRequest(http.MethodPost, "/api/managed/hooks", strings.NewReader(`{"tool":"pi","event":"PreToolUse","matcher":"Read","handlers":[{"type":"command","command":"./bin/check"}]}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from pi hook create, got %d: %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "does not support managed hooks") { + t.Fatalf("pi hook rejection body = %s, want managed family support error", rr.Body.String()) + } +} diff --git a/internal/server/handler_managed_rules.go b/internal/server/handler_managed_rules.go new file mode 100644 index 00000000..5d7cdff5 --- /dev/null +++ b/internal/server/handler_managed_rules.go @@ -0,0 +1,672 @@ +package server + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path" + "sort" + "strings" + + "skillshare/internal/config" + "skillshare/internal/inspect" + managed "skillshare/internal/resources/managed" + managedpi "skillshare/internal/resources/managed/pi" + managedrules "skillshare/internal/resources/rules" +) + +type managedRulePayload struct { + ID string `json:"id"` + Tool string `json:"tool"` + Name string `json:"name"` + RelativePath string `json:"relativePath"` + Content string `json:"content"` + Targets []string `json:"targets"` + SourceType string `json:"sourceType"` + Disabled bool `json:"disabled"` +} + +type managedRulePreview struct { + Target string `json:"target"` + Files []managedrules.CompiledFile `json:"files"` + Warnings []string `json:"warnings,omitempty"` +} + +type managedRuleRequest struct { + ID string `json:"id"` + Tool string `json:"tool"` + RelativePath string `json:"relativePath"` + Content *string `json:"content"` + Targets *[]string `json:"targets"` + SourceType *string `json:"sourceType"` + Disabled *bool `json:"disabled"` +} + +var validateManagedRuleSave = managed.ValidateManagedRuleSave + +func (s *Server) managedRulesProjectRoot() string { + if s.IsProjectMode() { + return s.projectRoot + } + return "" +} + +func (s *Server) handleListManagedRules(w http.ResponseWriter, r *http.Request) { + store := managedrules.NewStore(s.managedRulesProjectRoot()) + records, err := store.List() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list managed rules: "+err.Error()) + return + } + + items := make([]managedRulePayload, 0, len(records)) + for _, record := range records { + items = append(items, managedRulePayloadFromRecord(record)) + } + + writeJSON(w, map[string]any{"rules": items}) +} + +func (s *Server) handleCreateManagedRule(w http.ResponseWriter, r *http.Request) { + var body managedRuleRequest + if err := decodeManagedRuleRequest(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := validateManagedRuleSave(managed.RuleInput{ + Tool: body.Tool, + RelativePath: body.RelativePath, + Content: []byte(*body.Content), + }); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + store := managedrules.NewStore(s.managedRulesProjectRoot()) + + s.mu.Lock() + if _, err := store.Get(body.ID); err == nil { + s.mu.Unlock() + writeError(w, http.StatusConflict, "managed rule already exists: "+body.ID) + return + } else if !managedRuleNotFound(err) { + s.mu.Unlock() + writeError(w, managedRuleLoadStatus(err), "failed to check managed rule: "+err.Error()) + return + } + + record, err := store.Put(managedRuleSave(body, nil)) + s.mu.Unlock() + if err != nil { + writeError(w, managedRuleSaveStatus(err), "failed to save managed rule: "+err.Error()) + return + } + + s.writeManagedRuleDetail(w, http.StatusCreated, record) +} + +func (s *Server) handleGetManagedRule(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + writeError(w, http.StatusBadRequest, "rule id is required") + return + } + + record, status, err := s.loadManagedRule(id) + if err != nil { + writeError(w, status, err.Error()) + return + } + + s.writeManagedRuleDetail(w, http.StatusOK, record) +} + +func (s *Server) handleUpdateManagedRule(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + writeError(w, http.StatusBadRequest, "rule id is required") + return + } + + var body managedRuleRequest + if err := decodeManagedRuleRequest(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if body.ID != id { + writeError(w, http.StatusBadRequest, "rule id does not match request path") + return + } + if err := validateManagedRuleSave(managed.RuleInput{ + Tool: body.Tool, + RelativePath: body.RelativePath, + Content: []byte(*body.Content), + }); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + store := managedrules.NewStore(s.managedRulesProjectRoot()) + s.mu.Lock() + existing, err := store.Get(id) + if err != nil { + s.mu.Unlock() + writeError(w, managedRuleLoadStatus(err), err.Error()) + return + } + + record, err := store.Put(managedRuleSave(body, &existing)) + s.mu.Unlock() + if err != nil { + writeError(w, managedRuleSaveStatus(err), "failed to save managed rule: "+err.Error()) + return + } + + s.writeManagedRuleDetail(w, http.StatusOK, record) +} + +func (s *Server) handleDeleteManagedRule(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + writeError(w, http.StatusBadRequest, "rule id is required") + return + } + + store := managedrules.NewStore(s.managedRulesProjectRoot()) + if _, status, err := s.loadManagedRule(id); err != nil { + writeError(w, status, err.Error()) + return + } + + s.mu.Lock() + err := store.Delete(id) + s.mu.Unlock() + if err != nil { + status := http.StatusInternalServerError + if managedRuleNotFound(err) { + status = http.StatusNotFound + } + writeError(w, status, "failed to delete managed rule: "+err.Error()) + return + } + + writeJSON(w, map[string]any{"success": true}) +} + +func (s *Server) handleSetManagedRuleTargets(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + writeError(w, http.StatusBadRequest, "rule id is required") + return + } + + var body managedTargetsRequest + if err := decodeStrictJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + store := managedrules.NewStore(s.managedRulesProjectRoot()) + s.mu.Lock() + record, err := store.Get(id) + if err != nil { + s.mu.Unlock() + writeError(w, managedRuleLoadStatus(err), err.Error()) + return + } + if err := validateManagedRuleSave(managed.RuleInput{ + Tool: record.Tool, + RelativePath: record.RelativePath, + Content: record.Content, + }); err != nil { + s.mu.Unlock() + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + record, err = store.Put(managedrules.Save{ + ID: record.ID, + Content: record.Content, + Targets: normalizeManagedTargets([]string{body.Target}), + SourceType: record.SourceType, + Disabled: record.Disabled, + }) + s.mu.Unlock() + if err != nil { + writeError(w, managedRuleSaveStatus(err), "failed to save managed rule: "+err.Error()) + return + } + + s.writeManagedRuleDetail(w, http.StatusOK, record) +} + +func (s *Server) handleSetManagedRuleDisabled(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + writeError(w, http.StatusBadRequest, "rule id is required") + return + } + + var body managedDisabledRequest + if err := decodeStrictJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if body.Disabled == nil { + writeError(w, http.StatusBadRequest, "disabled is required") + return + } + + store := managedrules.NewStore(s.managedRulesProjectRoot()) + s.mu.Lock() + record, err := store.Get(id) + if err != nil { + s.mu.Unlock() + writeError(w, managedRuleLoadStatus(err), err.Error()) + return + } + if err := validateManagedRuleSave(managed.RuleInput{ + Tool: record.Tool, + RelativePath: record.RelativePath, + Content: record.Content, + }); err != nil { + s.mu.Unlock() + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + record, err = store.Put(managedrules.Save{ + ID: record.ID, + Content: record.Content, + Targets: append([]string(nil), record.Targets...), + SourceType: record.SourceType, + Disabled: *body.Disabled, + }) + s.mu.Unlock() + if err != nil { + writeError(w, managedRuleSaveStatus(err), "failed to save managed rule: "+err.Error()) + return + } + + s.writeManagedRuleDetail(w, http.StatusOK, record) +} + +func (s *Server) handleCollectManagedRules(w http.ResponseWriter, r *http.Request) { + var body struct { + IDs []string `json:"ids"` + Strategy managedrules.Strategy `json:"strategy"` + } + if err := decodeStrictJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if len(body.IDs) == 0 { + writeError(w, http.StatusBadRequest, "at least one rule id is required") + return + } + + projectRoot := s.managedRulesProjectRoot() + discovered, _, err := inspect.ScanRules(projectRoot) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to scan rules: "+err.Error()) + return + } + + discoveredByID := make(map[string]inspect.RuleItem, len(discovered)) + for _, item := range discovered { + discoveredByID[item.ID] = item + } + + selected := make([]inspect.RuleItem, 0, len(body.IDs)) + seenIDs := make(map[string]struct{}, len(body.IDs)) + for _, id := range body.IDs { + if _, seen := seenIDs[id]; seen { + continue + } + seenIDs[id] = struct{}{} + + item, ok := discoveredByID[id] + if !ok { + writeError(w, http.StatusBadRequest, "unknown discovered rule id: "+id) + return + } + selected = append(selected, item) + } + + s.mu.Lock() + result, err := managed.CollectRules(projectRoot, selected, body.Strategy) + s.mu.Unlock() + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, managedrules.ErrInvalidCollect) { + status = http.StatusBadRequest + } + writeError(w, status, "failed to collect managed rules: "+err.Error()) + return + } + + writeJSON(w, map[string]any{ + "created": result.Created, + "overwritten": result.Overwritten, + "skipped": result.Skipped, + }) +} + +func (s *Server) handleDiffManagedRules(w http.ResponseWriter, r *http.Request) { + store := managedrules.NewStore(s.managedRulesProjectRoot()) + records, err := store.List() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list managed rules: "+err.Error()) + return + } + + previews, err := s.compileManagedRulePreviews(records) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to compile managed rules diff: "+err.Error()) + return + } + + writeJSON(w, map[string]any{"diffs": previews}) +} + +func (s *Server) loadManagedRule(id string) (managedrules.Record, int, error) { + record, err := managedrules.NewStore(s.managedRulesProjectRoot()).Get(id) + if err != nil { + switch { + case errors.Is(err, managedrules.ErrInvalidID): + return managedrules.Record{}, http.StatusBadRequest, err + case managedRuleNotFound(err): + return managedrules.Record{}, http.StatusNotFound, err + default: + return managedrules.Record{}, http.StatusInternalServerError, err + } + } + return record, http.StatusOK, nil +} + +func (s *Server) writeManagedRuleDetail(w http.ResponseWriter, status int, record managedrules.Record) { + store := managedrules.NewStore(s.managedRulesProjectRoot()) + records, err := store.List() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load managed rule previews: "+err.Error()) + return + } + + previews, err := s.compileManagedRulePreviews(records) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to compile managed rule previews: "+err.Error()) + return + } + + writeJSONStatus(w, status, map[string]any{ + "rule": managedRulePayloadFromRecord(record), + "previews": previews, + }) +} + +func (s *Server) compileManagedRulePreviews(records []managedrules.Record) ([]managedRulePreview, error) { + targetNames := make([]string, 0, len(s.cfg.Targets)) + for name := range s.cfg.Targets { + targetNames = append(targetNames, name) + } + sort.Strings(targetNames) + + previews := make([]managedRulePreview, 0, len(targetNames)) + for _, name := range targetNames { + target := s.cfg.Targets[name] + compileTarget, compileRoot := s.resolveManagedRulePreviewTarget(name, target) + files, warnings, err := managedrules.CompileTarget(records, compileTarget, name, compileRoot) + if err != nil { + if errors.Is(err, managedrules.ErrUnsupportedTarget) { + previews = append(previews, managedRulePreview{ + Target: name, + Files: []managedrules.CompiledFile{}, + Warnings: []string{err.Error()}, + }) + continue + } + return nil, err + } + if files == nil { + files = []managedrules.CompiledFile{} + } + previews = append(previews, managedRulePreview{ + Target: name, + Files: files, + Warnings: warnings, + }) + } + return previews, nil +} + +func (s *Server) resolveManagedRulePreviewTarget(name string, target config.TargetConfig) (string, string) { + sc := target.SkillsConfig() + compileTarget, ok := managed.ResolveManagedFamily(managed.ResourceKindRules, name, sc.Path) + if !ok { + return name, sc.Path + } + + if s.IsProjectMode() { + return compileTarget, s.projectRoot + } + + return compileTarget, managed.RuleGlobalPreviewRoot(sc.Path) +} + +func decodeManagedRuleRequest(r *http.Request, body *managedRuleRequest) error { + if err := decodeStrictJSON(r, body); err != nil { + return errors.New("invalid request body: " + err.Error()) + } + + normalizedID := strings.TrimSpace(body.ID) + if normalizedID != "" { + var err error + normalizedID, err = managedrules.NormalizeRuleID(normalizedID) + if err != nil { + return err + } + } + + hasDerivedFields := strings.TrimSpace(body.Tool) != "" || strings.TrimSpace(body.RelativePath) != "" + if hasDerivedFields { + derivedID, err := managedRuleDerivedID(*body) + if err != nil { + return err + } + if normalizedID != "" && normalizedID != derivedID { + return errors.New("rule id does not match tool and relativePath") + } + normalizedID = derivedID + } + + if normalizedID == "" { + return errors.New("rule id is required") + } + if body.Content == nil { + return errors.New("content is required") + } + body.ID = normalizedID + if body.SourceType != nil { + sourceType := strings.TrimSpace(*body.SourceType) + body.SourceType = &sourceType + } + return nil +} + +type managedTargetsRequest struct { + Target string `json:"target"` +} + +type managedDisabledRequest struct { + Disabled *bool `json:"disabled"` +} + +func managedRulePayloadFromRecord(record managedrules.Record) managedRulePayload { + return managedRulePayload{ + ID: record.ID, + Tool: record.Tool, + Name: record.Name, + RelativePath: record.RelativePath, + Content: string(record.Content), + Targets: append([]string(nil), record.Targets...), + SourceType: record.SourceType, + Disabled: record.Disabled, + } +} + +func managedRuleSave(body managedRuleRequest, existing *managedrules.Record) managedrules.Save { + sourceType := "local" + disabled := false + var targets []string + if existing != nil { + sourceType = existing.SourceType + disabled = existing.Disabled + targets = append([]string(nil), existing.Targets...) + } + if body.Targets != nil { + targets = normalizeManagedTargets(*body.Targets) + } + if body.SourceType != nil { + sourceType = strings.TrimSpace(*body.SourceType) + } + if sourceType == "" { + sourceType = "local" + } + if body.Disabled != nil { + disabled = *body.Disabled + } + return managedrules.Save{ + ID: body.ID, + Content: []byte(*body.Content), + Targets: targets, + SourceType: sourceType, + Disabled: disabled, + } +} + +func normalizeManagedTargets(targets []string) []string { + if len(targets) == 0 { + return nil + } + out := make([]string, 0, len(targets)) + seen := make(map[string]struct{}, len(targets)) + for _, target := range targets { + target = strings.TrimSpace(target) + if target == "" { + continue + } + if _, ok := seen[target]; ok { + continue + } + seen[target] = struct{}{} + out = append(out, target) + } + if len(out) == 0 { + return nil + } + return out +} + +func managedRuleDerivedID(body managedRuleRequest) (string, error) { + tool, err := normalizeManagedRuleTool(body.Tool) + if err != nil { + return "", err + } + rawRel := strings.ReplaceAll(strings.TrimSpace(body.RelativePath), "\\", "/") + if tool == "" || rawRel == "" { + return "", errors.New("tool and relativePath are required together") + } + + if strings.HasPrefix(rawRel, "/") { + return "", errors.New("invalid rule relativePath") + } + if len(rawRel) >= 2 && rawRel[1] == ':' { + return "", errors.New("invalid rule relativePath") + } + for _, part := range strings.Split(rawRel, "/") { + if part == ".." { + return "", errors.New("invalid rule relativePath") + } + } + + rel := path.Clean(rawRel) + if rel == "." || rel == "/" { + return "", errors.New("invalid rule relativePath") + } + if tool == "pi" { + normalized, ok := managedpi.NormalizeManagedRuleID(rel) + if !ok { + return "", errors.New("invalid rule relativePath") + } + return normalized, nil + } + if strings.HasPrefix(rel, tool+"/") { + normalized, err := managedrules.NormalizeRuleID(rel) + if err != nil { + return "", errors.New("invalid rule relativePath") + } + return normalized, nil + } + normalized, err := managedrules.NormalizeRuleID(tool + "/" + strings.TrimPrefix(rel, "/")) + if err != nil { + return "", errors.New("invalid rule relativePath") + } + return normalized, nil +} + +func normalizeManagedRuleTool(raw string) (string, error) { + tool := strings.ToLower(strings.TrimSpace(raw)) + switch tool { + case "claude", "codex", "gemini", "pi": + return tool, nil + case "": + return "", nil + default: + return "", errors.New("unsupported rule tool") + } +} + +func decodeStrictJSON(r *http.Request, dst any) error { + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + return err + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return errors.New("unexpected extra JSON value") + } + return err + } + return nil +} + +func managedRuleNotFound(err error) bool { + return errors.Is(err, os.ErrNotExist) +} + +func managedRuleSaveStatus(err error) int { + if errors.Is(err, managedrules.ErrInvalidID) { + return http.StatusBadRequest + } + return http.StatusInternalServerError +} + +func managedRuleLoadStatus(err error) int { + switch { + case errors.Is(err, managedrules.ErrInvalidID): + return http.StatusBadRequest + case managedRuleNotFound(err): + return http.StatusNotFound + default: + return http.StatusInternalServerError + } +} + +func writeJSONStatus(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} diff --git a/internal/server/handler_managed_rules_test.go b/internal/server/handler_managed_rules_test.go new file mode 100644 index 00000000..f0599284 --- /dev/null +++ b/internal/server/handler_managed_rules_test.go @@ -0,0 +1,1151 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "skillshare/internal/config" + "skillshare/internal/inspect" + managed "skillshare/internal/resources/managed" + managedrules "skillshare/internal/resources/rules" +) + +func TestManagedRulesCRUDAndCollect(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude") + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"claude/manual.md","content":"# Managed\n"}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + var createResp struct { + Rule struct { + ID string `json:"id"` + Content string `json:"content"` + } `json:"rule"` + Previews []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + Content string `json:"content"` + Format string `json:"format"` + } `json:"files"` + } `json:"previews"` + } + if err := json.Unmarshal(createRR.Body.Bytes(), &createResp); err != nil { + t.Fatalf("failed to decode create response: %v", err) + } + if createResp.Rule.ID != "claude/manual.md" { + t.Fatalf("create response rule id = %q, want %q", createResp.Rule.ID, "claude/manual.md") + } + if len(createResp.Previews) != 1 || createResp.Previews[0].Target != "claude" { + t.Fatalf("create previews = %#v, want one claude preview", createResp.Previews) + } + if len(createResp.Previews[0].Files) == 0 || createResp.Previews[0].Files[0].Path != filepath.Join(projectRoot, ".claude", "rules", "manual.md") { + t.Fatalf("create preview files = %#v, want compiled claude rule output", createResp.Previews[0].Files) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/managed/rules/claude/manual.md", nil) + getRR := httptest.NewRecorder() + s.handler.ServeHTTP(getRR, getReq) + if getRR.Code != http.StatusOK { + t.Fatalf("expected 200 from get, got %d: %s", getRR.Code, getRR.Body.String()) + } + + var getResp struct { + Rule struct { + ID string `json:"id"` + Content string `json:"content"` + } `json:"rule"` + Previews []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + Content string `json:"content"` + Format string `json:"format"` + } `json:"files"` + } `json:"previews"` + } + if err := json.Unmarshal(getRR.Body.Bytes(), &getResp); err != nil { + t.Fatalf("failed to decode get response: %v", err) + } + if getResp.Rule.Content != "# Managed\n" { + t.Fatalf("get response content = %q, want %q", getResp.Rule.Content, "# Managed\n") + } + if len(getResp.Previews) != 1 || len(getResp.Previews[0].Files) == 0 { + t.Fatalf("get previews = %#v, want compiled preview data", getResp.Previews) + } + + updateReq := httptest.NewRequest(http.MethodPut, "/api/managed/rules/claude/manual.md", strings.NewReader(`{"tool":"claude","relativePath":"claude/manual.md","content":"# Updated\n"}`)) + updateRR := httptest.NewRecorder() + s.handler.ServeHTTP(updateRR, updateReq) + if updateRR.Code != http.StatusOK { + t.Fatalf("expected 200 from update, got %d: %s", updateRR.Code, updateRR.Body.String()) + } + + var updateResp struct { + Rule struct { + Content string `json:"content"` + } `json:"rule"` + } + if err := json.Unmarshal(updateRR.Body.Bytes(), &updateResp); err != nil { + t.Fatalf("failed to decode update response: %v", err) + } + if updateResp.Rule.Content != "# Updated\n" { + t.Fatalf("update response content = %q, want %q", updateResp.Rule.Content, "# Updated\n") + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/api/managed/rules/claude/manual.md", nil) + deleteRR := httptest.NewRecorder() + s.handler.ServeHTTP(deleteRR, deleteReq) + if deleteRR.Code != http.StatusOK { + t.Fatalf("expected 200 from delete, got %d: %s", deleteRR.Code, deleteRR.Body.String()) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/managed/rules", nil) + listRR := httptest.NewRecorder() + s.handler.ServeHTTP(listRR, listReq) + if listRR.Code != http.StatusOK { + t.Fatalf("expected 200 from list, got %d: %s", listRR.Code, listRR.Body.String()) + } + var listResp struct { + Rules []struct { + ID string `json:"id"` + } `json:"rules"` + } + if err := json.Unmarshal(listRR.Body.Bytes(), &listResp); err != nil { + t.Fatalf("failed to decode list response: %v", err) + } + if len(listResp.Rules) != 0 { + t.Fatalf("expected 0 rules after delete, got %d", len(listResp.Rules)) + } + + discoveredPath := filepath.Join(projectRoot, ".claude", "rules", "seed.md") + if err := os.MkdirAll(filepath.Dir(discoveredPath), 0755); err != nil { + t.Fatalf("failed to create discovered rule dir: %v", err) + } + if err := os.WriteFile(discoveredPath, []byte("# Seed\n"), 0644); err != nil { + t.Fatalf("failed to write discovered rule: %v", err) + } + + discovered, _, err := inspect.ScanRules(projectRoot) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + var discoveredID string + for _, item := range discovered { + if item.Path == discoveredPath { + discoveredID = item.ID + break + } + } + if discoveredID == "" { + t.Fatalf("failed to find discovered rule id for %s", discoveredPath) + } + + collectReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules/collect", strings.NewReader(`{"ids":["`+discoveredID+`"],"strategy":"overwrite"}`)) + collectRR := httptest.NewRecorder() + s.handler.ServeHTTP(collectRR, collectReq) + if collectRR.Code != http.StatusOK { + t.Fatalf("expected 200 from collect, got %d: %s", collectRR.Code, collectRR.Body.String()) + } + + var collectResp struct { + Created []string `json:"created"` + Overwritten []string `json:"overwritten"` + Skipped []string `json:"skipped"` + } + if err := json.Unmarshal(collectRR.Body.Bytes(), &collectResp); err != nil { + t.Fatalf("failed to decode collect response: %v", err) + } + if len(collectResp.Created) != 1 { + t.Fatalf("expected one created managed rule, got %#v", collectResp.Created) + } + + managedPath := filepath.Join(projectRoot, ".skillshare", "rules", filepath.FromSlash(collectResp.Created[0])) + if _, err := os.Stat(managedPath); err != nil { + t.Fatalf("expected managed rule file at %s: %v", managedPath, err) + } + + diffReq := httptest.NewRequest(http.MethodGet, "/api/managed/rules/diff", nil) + diffRR := httptest.NewRecorder() + s.handler.ServeHTTP(diffRR, diffReq) + if diffRR.Code != http.StatusOK { + t.Fatalf("expected 200 from diff, got %d: %s", diffRR.Code, diffRR.Body.String()) + } + + var diffResp struct { + Diffs []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + Content string `json:"content"` + Format string `json:"format"` + } `json:"files"` + } `json:"diffs"` + } + if err := json.Unmarshal(diffRR.Body.Bytes(), &diffResp); err != nil { + t.Fatalf("failed to decode diff response: %v", err) + } + if len(diffResp.Diffs) != 1 || diffResp.Diffs[0].Target != "claude" { + t.Fatalf("diff response = %#v, want one claude diff", diffResp.Diffs) + } + if len(diffResp.Diffs[0].Files) == 0 || diffResp.Diffs[0].Files[0].Path != filepath.Join(projectRoot, ".claude", "rules", filepath.Base(managedPath)) { + t.Fatalf("diff files = %#v, want compiled preview output under target path", diffResp.Diffs[0].Files) + } +} + +func TestHandleManagedRules_CreateAndUpdateExposeMetadata(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"claude/manual.md","content":"# Managed\n","targets":["claude-work"],"sourceType":"tracked","disabled":true}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + var createResp struct { + Rule struct { + Targets []string `json:"targets"` + SourceType string `json:"sourceType"` + Disabled bool `json:"disabled"` + } `json:"rule"` + } + if err := json.Unmarshal(createRR.Body.Bytes(), &createResp); err != nil { + t.Fatalf("failed to decode create response: %v", err) + } + if len(createResp.Rule.Targets) != 1 || createResp.Rule.Targets[0] != "claude-work" { + t.Fatalf("create targets = %v, want [claude-work]", createResp.Rule.Targets) + } + if createResp.Rule.SourceType != "tracked" { + t.Fatalf("create sourceType = %q, want %q", createResp.Rule.SourceType, "tracked") + } + if !createResp.Rule.Disabled { + t.Fatalf("create disabled = %v, want true", createResp.Rule.Disabled) + } + + updateReq := httptest.NewRequest(http.MethodPut, "/api/managed/rules/claude/manual.md", strings.NewReader(`{"id":"claude/manual.md","tool":"claude","relativePath":"claude/manual.md","content":"# Updated\n"}`)) + updateRR := httptest.NewRecorder() + s.handler.ServeHTTP(updateRR, updateReq) + if updateRR.Code != http.StatusOK { + t.Fatalf("expected 200 from update, got %d: %s", updateRR.Code, updateRR.Body.String()) + } + + var updateResp struct { + Rule struct { + Targets []string `json:"targets"` + SourceType string `json:"sourceType"` + Disabled bool `json:"disabled"` + } `json:"rule"` + } + if err := json.Unmarshal(updateRR.Body.Bytes(), &updateResp); err != nil { + t.Fatalf("failed to decode update response: %v", err) + } + if len(updateResp.Rule.Targets) != 1 || updateResp.Rule.Targets[0] != "claude-work" { + t.Fatalf("update targets = %v, want [claude-work]", updateResp.Rule.Targets) + } + if updateResp.Rule.SourceType != "tracked" { + t.Fatalf("update sourceType = %q, want %q", updateResp.Rule.SourceType, "tracked") + } + if !updateResp.Rule.Disabled { + t.Fatalf("update disabled = %v, want true", updateResp.Rule.Disabled) + } +} + +func TestHandleManagedRuleTargets_PersistsAssignedTarget(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude") + store := managedrules.NewStore(projectRoot) + if _, err := store.Put(managedrules.Save{ + ID: "claude/manual.md", + Content: []byte("# Manual\n"), + SourceType: "local", + }); err != nil { + t.Fatalf("put rule: %v", err) + } + + req := httptest.NewRequest(http.MethodPatch, "/api/managed/rules/claude%2Fmanual.md/targets", strings.NewReader(`{"target":"claude-work"}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rr.Code, http.StatusOK, rr.Body.String()) + } + + got, err := store.Get("claude/manual.md") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if len(got.Targets) != 1 || got.Targets[0] != "claude-work" { + t.Fatalf("targets = %v, want [claude-work]", got.Targets) + } + + clearReq := httptest.NewRequest(http.MethodPatch, "/api/managed/rules/claude%2Fmanual.md/targets", strings.NewReader(`{"target":""}`)) + clearRR := httptest.NewRecorder() + s.handler.ServeHTTP(clearRR, clearReq) + if clearRR.Code != http.StatusOK { + t.Fatalf("clear status = %d, want %d: %s", clearRR.Code, http.StatusOK, clearRR.Body.String()) + } + + got, err = store.Get("claude/manual.md") + if err != nil { + t.Fatalf("Get() after clear error = %v", err) + } + if got.Targets != nil { + t.Fatalf("targets after clear = %v, want nil", got.Targets) + } +} + +func TestManagedRulesDetailPreviewIncludesFullCodexAggregate(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "codex") + + for _, body := range []string{ + `{"tool":"codex","relativePath":"codex/one.md","content":"# One\n"}`, + `{"tool":"codex","relativePath":"codex/two.md","content":"# Two\n"}`, + } { + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(body)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", rr.Code, rr.Body.String()) + } + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/managed/rules/codex/one.md", nil) + getRR := httptest.NewRecorder() + s.handler.ServeHTTP(getRR, getReq) + if getRR.Code != http.StatusOK { + t.Fatalf("expected 200 from get, got %d: %s", getRR.Code, getRR.Body.String()) + } + + var getResp struct { + Previews []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + Content string `json:"content"` + } `json:"files"` + } `json:"previews"` + } + if err := json.Unmarshal(getRR.Body.Bytes(), &getResp); err != nil { + t.Fatalf("failed to decode get response: %v", err) + } + + var codexPreview *struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + Content string `json:"content"` + } `json:"files"` + } + for i := range getResp.Previews { + if getResp.Previews[i].Target == "codex" { + codexPreview = &getResp.Previews[i] + break + } + } + if codexPreview == nil { + t.Fatalf("expected codex preview in %#v", getResp.Previews) + } + if len(codexPreview.Files) != 1 { + t.Fatalf("expected one codex compiled file, got %#v", codexPreview.Files) + } + if codexPreview.Files[0].Path != filepath.Join(projectRoot, "AGENTS.md") { + t.Fatalf("codex preview path = %q, want %q", codexPreview.Files[0].Path, filepath.Join(projectRoot, "AGENTS.md")) + } + if !strings.Contains(codexPreview.Files[0].Content, "skillshare:codex/one.md") || !strings.Contains(codexPreview.Files[0].Content, "skillshare:codex/two.md") { + t.Fatalf("codex preview content = %q, want aggregate output containing both codex rules", codexPreview.Files[0].Content) + } +} + +func TestManagedRulesDiffResolvesAliasTargetToClaudeProjectRuleRoot(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude-code") + + targetPath := filepath.Join(projectRoot, ".claude", "skills") + if err := os.MkdirAll(targetPath, 0755); err != nil { + t.Fatalf("failed to create target dir: %v", err) + } + s.cfg.Targets["claude-code"] = config.TargetConfig{Path: targetPath} + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"claude/manual.md","content":"# Managed\n"}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + diffReq := httptest.NewRequest(http.MethodGet, "/api/managed/rules/diff", nil) + diffRR := httptest.NewRecorder() + s.handler.ServeHTTP(diffRR, diffReq) + if diffRR.Code != http.StatusOK { + t.Fatalf("expected 200 from diff, got %d: %s", diffRR.Code, diffRR.Body.String()) + } + + var diffResp struct { + Diffs []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + } `json:"files"` + Warnings []string `json:"warnings"` + } `json:"diffs"` + } + if err := json.Unmarshal(diffRR.Body.Bytes(), &diffResp); err != nil { + t.Fatalf("failed to decode diff response: %v", err) + } + if len(diffResp.Diffs) != 1 || diffResp.Diffs[0].Target != "claude-code" { + t.Fatalf("diff response = %#v, want one claude-code diff", diffResp.Diffs) + } + if len(diffResp.Diffs[0].Warnings) != 0 { + t.Fatalf("diff warnings = %#v, want none", diffResp.Diffs[0].Warnings) + } + if len(diffResp.Diffs[0].Files) != 1 || diffResp.Diffs[0].Files[0].Path != filepath.Join(projectRoot, ".claude", "rules", "manual.md") { + t.Fatalf("diff files = %#v, want compiled preview output under project rule root", diffResp.Diffs[0].Files) + } +} + +func TestManagedRulesPreviewCompilesSharedAgentsTargetsAtProjectRoot(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "universal") + + targetPath := filepath.Join(projectRoot, ".agents", "skills") + if err := os.MkdirAll(targetPath, 0755); err != nil { + t.Fatalf("failed to create target dir: %v", err) + } + s.cfg.Targets["universal"] = config.TargetConfig{Path: targetPath} + + for _, body := range []string{ + `{"tool":"codex","relativePath":"codex/one.md","content":"# One\n"}`, + `{"tool":"codex","relativePath":"codex/two.md","content":"# Two\n"}`, + } { + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(body)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", rr.Code, rr.Body.String()) + } + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/managed/rules/codex/one.md", nil) + getRR := httptest.NewRecorder() + s.handler.ServeHTTP(getRR, getReq) + if getRR.Code != http.StatusOK { + t.Fatalf("expected 200 from get, got %d: %s", getRR.Code, getRR.Body.String()) + } + + var getResp struct { + Previews []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + Content string `json:"content"` + } `json:"files"` + Warnings []string `json:"warnings"` + } `json:"previews"` + } + if err := json.Unmarshal(getRR.Body.Bytes(), &getResp); err != nil { + t.Fatalf("failed to decode get response: %v", err) + } + + if len(getResp.Previews) != 1 || getResp.Previews[0].Target != "universal" { + t.Fatalf("get previews = %#v, want one universal preview", getResp.Previews) + } + if len(getResp.Previews[0].Warnings) != 0 { + t.Fatalf("get preview warnings = %#v, want none", getResp.Previews[0].Warnings) + } + if len(getResp.Previews[0].Files) != 1 || getResp.Previews[0].Files[0].Path != filepath.Join(projectRoot, "AGENTS.md") { + t.Fatalf("get preview files = %#v, want AGENTS.md at project root", getResp.Previews[0].Files) + } + if !strings.Contains(getResp.Previews[0].Files[0].Content, "skillshare:codex/one.md") || !strings.Contains(getResp.Previews[0].Files[0].Content, "skillshare:codex/two.md") { + t.Fatalf("get preview content = %q, want aggregate codex output", getResp.Previews[0].Files[0].Content) + } +} + +func TestManagedRulesDiffResolvesAliasTargetToClaudeGlobalRuleRoot(t *testing.T) { + tmp := t.TempDir() + homeDir := filepath.Join(tmp, "home") + t.Setenv("HOME", homeDir) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "xdg-config")) + + targetPath := filepath.Join(homeDir, ".claude", "skills") + s, _ := newTestServerWithTargets(t, map[string]string{"claude-code": targetPath}) + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"claude/manual.md","content":"# Managed\n"}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + diffReq := httptest.NewRequest(http.MethodGet, "/api/managed/rules/diff", nil) + diffRR := httptest.NewRecorder() + s.handler.ServeHTTP(diffRR, diffReq) + if diffRR.Code != http.StatusOK { + t.Fatalf("expected 200 from diff, got %d: %s", diffRR.Code, diffRR.Body.String()) + } + + var diffResp struct { + Diffs []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + } `json:"files"` + Warnings []string `json:"warnings"` + } `json:"diffs"` + } + if err := json.Unmarshal(diffRR.Body.Bytes(), &diffResp); err != nil { + t.Fatalf("failed to decode diff response: %v", err) + } + if len(diffResp.Diffs) != 1 || diffResp.Diffs[0].Target != "claude-code" { + t.Fatalf("diff response = %#v, want one claude-code diff", diffResp.Diffs) + } + if len(diffResp.Diffs[0].Warnings) != 0 { + t.Fatalf("diff warnings = %#v, want none", diffResp.Diffs[0].Warnings) + } + if len(diffResp.Diffs[0].Files) != 1 || diffResp.Diffs[0].Files[0].Path != filepath.Join(homeDir, ".claude", "rules", "manual.md") { + t.Fatalf("diff files = %#v, want compiled output under global claude root", diffResp.Diffs[0].Files) + } +} + +func TestManagedRulesPreviewCompilesSharedAgentsTargetsAtGlobalRoot(t *testing.T) { + tmp := t.TempDir() + homeDir := filepath.Join(tmp, "home") + t.Setenv("HOME", homeDir) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "xdg-config")) + + targetPath := filepath.Join(homeDir, ".agents", "skills") + s, _ := newTestServerWithTargets(t, map[string]string{"universal": targetPath}) + + for _, body := range []string{ + `{"tool":"codex","relativePath":"codex/one.md","content":"# One\n"}`, + `{"tool":"codex","relativePath":"codex/two.md","content":"# Two\n"}`, + } { + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(body)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", rr.Code, rr.Body.String()) + } + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/managed/rules/codex/one.md", nil) + getRR := httptest.NewRecorder() + s.handler.ServeHTTP(getRR, getReq) + if getRR.Code != http.StatusOK { + t.Fatalf("expected 200 from get, got %d: %s", getRR.Code, getRR.Body.String()) + } + + var getResp struct { + Previews []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + Content string `json:"content"` + } `json:"files"` + Warnings []string `json:"warnings"` + } `json:"previews"` + } + if err := json.Unmarshal(getRR.Body.Bytes(), &getResp); err != nil { + t.Fatalf("failed to decode get response: %v", err) + } + if len(getResp.Previews) != 1 || getResp.Previews[0].Target != "universal" { + t.Fatalf("get previews = %#v, want one universal preview", getResp.Previews) + } + if len(getResp.Previews[0].Warnings) != 0 { + t.Fatalf("get preview warnings = %#v, want none", getResp.Previews[0].Warnings) + } + if len(getResp.Previews[0].Files) != 1 || getResp.Previews[0].Files[0].Path != filepath.Join(homeDir, ".agents", "AGENTS.md") { + t.Fatalf("get preview files = %#v, want AGENTS.md under global .agents root", getResp.Previews[0].Files) + } + if !strings.Contains(getResp.Previews[0].Files[0].Content, "skillshare:codex/one.md") || !strings.Contains(getResp.Previews[0].Files[0].Content, "skillshare:codex/two.md") { + t.Fatalf("get preview content = %q, want aggregate codex output", getResp.Previews[0].Files[0].Content) + } +} + +func TestManagedRulesPreviewCompilesPiTargetsAtGlobalRoot(t *testing.T) { + tmp := t.TempDir() + homeDir := filepath.Join(tmp, "home") + t.Setenv("HOME", homeDir) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "xdg-config")) + + targetPath := filepath.Join(homeDir, ".pi", "agent", "skills") + s, _ := newTestServerWithTargets(t, map[string]string{"pi": targetPath}) + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"pi","relativePath":"pi/SYSTEM.md","content":"# Pi System\n"}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + var createResp struct { + Previews []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + } `json:"files"` + } `json:"previews"` + } + if err := json.Unmarshal(createRR.Body.Bytes(), &createResp); err != nil { + t.Fatalf("failed to decode create response: %v", err) + } + if len(createResp.Previews) != 1 || createResp.Previews[0].Target != "pi" { + t.Fatalf("create previews = %#v, want one pi preview", createResp.Previews) + } + if len(createResp.Previews[0].Files) != 1 || createResp.Previews[0].Files[0].Path != filepath.Join(homeDir, ".pi", "agent", "SYSTEM.md") { + t.Fatalf("create preview files = %#v, want SYSTEM.md under ~/.pi/agent", createResp.Previews[0].Files) + } +} + +func TestManagedRulesCreateAcceptsNestedPiAgentsPath(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "pi") + + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"pi","relativePath":"pi/nested/AGENTS.md","content":"# Nested Pi\n"}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Rule struct { + ID string `json:"id"` + } `json:"rule"` + Previews []struct { + Target string `json:"target"` + Files []struct { + Path string `json:"path"` + } `json:"files"` + } `json:"previews"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode create response: %v", err) + } + if resp.Rule.ID != "pi/nested/AGENTS.md" { + t.Fatalf("create response rule id = %q, want %q", resp.Rule.ID, "pi/nested/AGENTS.md") + } + if len(resp.Previews) != 1 || resp.Previews[0].Target != "pi" { + t.Fatalf("create previews = %#v, want one pi preview", resp.Previews) + } + if len(resp.Previews[0].Files) != 1 || resp.Previews[0].Files[0].Path != filepath.Join(projectRoot, "nested", "AGENTS.md") { + t.Fatalf("create preview files = %#v, want nested AGENTS.md under project root", resp.Previews[0].Files) + } +} + +func TestManagedRulesCreateRejectsUnsupportedPiPath(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "pi") + + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"pi","relativePath":"pi/extra.md","content":"# Extra\n"}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from create, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestManagedRulesCollectDuplicatePiSurfaceReturnsBadRequest(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "pi") + + store := managedrules.NewStore(projectRoot) + if _, err := store.Put(managedrules.Save{ + ID: "pi/SYSTEM.md", + Content: []byte("# Existing Pi System\n"), + }); err != nil { + t.Fatalf("seed Put() error = %v", err) + } + + systemPath := filepath.Join(projectRoot, ".pi", "SYSTEM.md") + if err := os.MkdirAll(filepath.Dir(systemPath), 0755); err != nil { + t.Fatalf("failed to create pi dir: %v", err) + } + if err := os.WriteFile(systemPath, []byte("# Pi System\n"), 0644); err != nil { + t.Fatalf("failed to write pi system file: %v", err) + } + + discovered, _, err := inspect.ScanRules(projectRoot) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + + var discoveredID string + for _, item := range discovered { + if item.Path == systemPath { + discoveredID = item.ID + break + } + } + if discoveredID == "" { + t.Fatalf("failed to find discovered rule id for %s", systemPath) + } + + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules/collect", strings.NewReader(`{"ids":["`+discoveredID+`"],"strategy":"duplicate"}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from collect, got %d: %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "fixed instruction surface") { + t.Fatalf("collect response = %s, want fixed-surface validation message", rr.Body.String()) + } +} + +func TestManagedRulesCreateServerErrorOnWriteFailure(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude") + + blockingPath := filepath.Join(projectRoot, ".skillshare", "rules") + if err := os.WriteFile(blockingPath, []byte("block"), 0644); err != nil { + t.Fatalf("failed to create blocking rules file: %v", err) + } + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"claude/manual.md","content":"# Managed\n"}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } +} + +func TestManagedRulesCreateRejectsEscapingRelativePath(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"../foo.md","content":"# Bad\n"}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } +} + +func TestManagedRulesCreateRejectsDuplicateRule(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + firstReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"claude/manual.md","content":"# First\n"}`)) + firstRR := httptest.NewRecorder() + s.handler.ServeHTTP(firstRR, firstReq) + if firstRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from first create, got %d: %s", firstRR.Code, firstRR.Body.String()) + } + + secondReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"claude/manual.md","content":"# Second\n"}`)) + secondRR := httptest.NewRecorder() + s.handler.ServeHTTP(secondRR, secondReq) + if secondRR.Code != http.StatusConflict { + t.Fatalf("expected 409 from duplicate create, got %d: %s", secondRR.Code, secondRR.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/managed/rules/claude/manual.md", nil) + getRR := httptest.NewRecorder() + s.handler.ServeHTTP(getRR, getReq) + if getRR.Code != http.StatusOK { + t.Fatalf("expected 200 from get, got %d: %s", getRR.Code, getRR.Body.String()) + } + + var getResp struct { + Rule struct { + Content string `json:"content"` + } `json:"rule"` + } + if err := json.Unmarshal(getRR.Body.Bytes(), &getResp); err != nil { + t.Fatalf("failed to decode get response: %v", err) + } + if getResp.Rule.Content != "# First\n" { + t.Fatalf("rule content after duplicate create = %q, want original content", getResp.Rule.Content) + } +} + +func TestManagedRulesCreateRejectsMissingContent(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"claude/manual.md"}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from create, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestManagedRulesCreateRejectsInvalidOrUnsupportedTool(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + for _, tool := range []string{"foo", "foo/bar", "foo/../codex"} { + t.Run(tool, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"`+tool+`","relativePath":"manual.md","content":"# Bad\n"}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("tool %q: expected 400 from create, got %d: %s", tool, rr.Code, rr.Body.String()) + } + }) + } +} + +func TestManagedRulesCreateRejectsUnsupportedIDOnlyRule(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"id":"foo/bar.md","content":"# Bad\n"}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from create, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestManagedRulesUpdateRejectsUnsupportedIDOnlyRule(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"claude/good.md","content":"# Good\n"}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + updateReq := httptest.NewRequest(http.MethodPut, "/api/managed/rules/foo/bar.md", strings.NewReader(`{"id":"foo/bar.md","content":"# Bad\n"}`)) + updateRR := httptest.NewRecorder() + s.handler.ServeHTTP(updateRR, updateReq) + if updateRR.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from update, got %d: %s", updateRR.Code, updateRR.Body.String()) + } +} + +func TestManagedRulesCreateRejectsBareToolPrefixAndReservedTempID(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + for name, body := range map[string]string{ + "bare tool prefix": `{"id":"claude","content":"# Bad\n"}`, + "reserved temp path": `{"tool":"claude","relativePath":"claude/.rule-tmp-manual.md","content":"# Bad\n"}`, + } { + t.Run(name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(body)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from create, got %d: %s", rr.Code, rr.Body.String()) + } + }) + } +} + +func TestManagedRulesUnsupportedTargetPreviewWarning(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "cursor") + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"claude/manual.md","content":"# Managed\n"}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + var createResp struct { + Previews []struct { + Target string `json:"target"` + Files []struct{} `json:"files"` + Warnings []string `json:"warnings"` + } `json:"previews"` + } + if err := json.Unmarshal(createRR.Body.Bytes(), &createResp); err != nil { + t.Fatalf("failed to decode create response: %v", err) + } + if len(createResp.Previews) != 1 || createResp.Previews[0].Target != "cursor" { + t.Fatalf("create previews = %#v, want one cursor preview", createResp.Previews) + } + if len(createResp.Previews[0].Files) != 0 { + t.Fatalf("create preview files = %#v, want empty files for unsupported target", createResp.Previews[0].Files) + } + if len(createResp.Previews[0].Warnings) == 0 { + t.Fatalf("create preview warnings = %#v, want unsupported-target warning", createResp.Previews[0].Warnings) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/managed/rules/claude/manual.md", nil) + getRR := httptest.NewRecorder() + s.handler.ServeHTTP(getRR, getReq) + if getRR.Code != http.StatusOK { + t.Fatalf("expected 200 from get, got %d: %s", getRR.Code, getRR.Body.String()) + } + + var getResp struct { + Previews []struct { + Target string `json:"target"` + Files []struct{} `json:"files"` + Warnings []string `json:"warnings"` + } `json:"previews"` + } + if err := json.Unmarshal(getRR.Body.Bytes(), &getResp); err != nil { + t.Fatalf("failed to decode get response: %v", err) + } + if len(getResp.Previews) != 1 || getResp.Previews[0].Target != "cursor" { + t.Fatalf("get previews = %#v, want one cursor preview", getResp.Previews) + } + if len(getResp.Previews[0].Files) != 0 { + t.Fatalf("get preview files = %#v, want empty files for unsupported target", getResp.Previews[0].Files) + } + if len(getResp.Previews[0].Warnings) == 0 { + t.Fatalf("get preview warnings = %#v, want unsupported-target warning", getResp.Previews[0].Warnings) + } +} + +func TestManagedRulesCreateRejectsWindowsStyleRelativePath(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + for _, relativePath := range []string{ + "C:/outside.md", + "C:outside.md", + "claude/C:/outside.md", + } { + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"`+relativePath+`","content":"# Bad\n"}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("relativePath %q: expected 400 from create, got %d: %s", relativePath, rr.Code, rr.Body.String()) + } + } +} + +func TestManagedRulesCreateRejectsInvalidRelativePathWhenIDProvided(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"id":"claude/good.md","tool":"claude","relativePath":"../escape.md","content":"# Bad\n"}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from create, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestManagedRulesUpdateRejectsMismatchedIDAndRelativePath(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"claude/good.md","content":"# Good\n"}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + updateReq := httptest.NewRequest(http.MethodPut, "/api/managed/rules/claude/good.md", strings.NewReader(`{"id":"claude/good.md","tool":"claude","relativePath":"claude/other.md","content":"# Bad\n"}`)) + updateRR := httptest.NewRecorder() + s.handler.ServeHTTP(updateRR, updateReq) + if updateRR.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from update, got %d: %s", updateRR.Code, updateRR.Body.String()) + } +} + +func TestManagedRulesUpdateRejectsMissingContent(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"claude/manual.md","content":"# Managed\n"}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + updateReq := httptest.NewRequest(http.MethodPut, "/api/managed/rules/claude/manual.md", strings.NewReader(`{"tool":"claude","relativePath":"claude/manual.md"}`)) + updateRR := httptest.NewRecorder() + s.handler.ServeHTTP(updateRR, updateReq) + if updateRR.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from update, got %d: %s", updateRR.Code, updateRR.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/managed/rules/claude/manual.md", nil) + getRR := httptest.NewRecorder() + s.handler.ServeHTTP(getRR, getReq) + if getRR.Code != http.StatusOK { + t.Fatalf("expected 200 from get, got %d: %s", getRR.Code, getRR.Body.String()) + } + + var getResp struct { + Rule struct { + Content string `json:"content"` + } `json:"rule"` + } + if err := json.Unmarshal(getRR.Body.Bytes(), &getResp); err != nil { + t.Fatalf("failed to decode get response: %v", err) + } + if getResp.Rule.Content != "# Managed\n" { + t.Fatalf("rule content after rejected update = %q, want original content", getResp.Rule.Content) + } +} + +func TestManagedRulesUpdateMissingRuleReturnsNotFound(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + req := httptest.NewRequest(http.MethodPut, "/api/managed/rules/claude/missing.md", strings.NewReader(`{"id":"claude/missing.md","tool":"claude","relativePath":"claude/missing.md","content":"# Missing\n"}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404 from update, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestManagedRulesCreateRejectsUnknownFieldsAndTrailingJSON(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + for name, body := range map[string]string{ + "unknown field": `{"tool":"claude","relativePath":"claude/manual.md","content":"# Managed\n","extra":true}`, + "trailing json": `{"tool":"claude","relativePath":"claude/manual.md","content":"# Managed\n"}{"extra":true}`, + } { + t.Run(name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(body)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from create, got %d: %s", rr.Code, rr.Body.String()) + } + }) + } +} + +func TestManagedRulesUpdateRejectsUnknownFieldsAndTrailingJSON(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + createReq := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"claude/manual.md","content":"# Managed\n"}`)) + createRR := httptest.NewRecorder() + s.handler.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusCreated { + t.Fatalf("expected 201 from create, got %d: %s", createRR.Code, createRR.Body.String()) + } + + for name, body := range map[string]string{ + "unknown field": `{"tool":"claude","relativePath":"claude/manual.md","content":"# Updated\n","extra":true}`, + "trailing json": `{"tool":"claude","relativePath":"claude/manual.md","content":"# Updated\n"}{"extra":true}`, + } { + t.Run(name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPut, "/api/managed/rules/claude/manual.md", strings.NewReader(body)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from update, got %d: %s", rr.Code, rr.Body.String()) + } + }) + } +} + +func TestManagedRulesCollectRejectsUnknownFieldsAndTrailingJSON(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude") + + discoveredPath := filepath.Join(projectRoot, ".claude", "rules", "seed.md") + if err := os.MkdirAll(filepath.Dir(discoveredPath), 0755); err != nil { + t.Fatalf("failed to create discovered rule dir: %v", err) + } + if err := os.WriteFile(discoveredPath, []byte("# Seed\n"), 0644); err != nil { + t.Fatalf("failed to write discovered rule: %v", err) + } + + discovered, _, err := inspect.ScanRules(projectRoot) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + var discoveredID string + for _, item := range discovered { + if item.Path == discoveredPath { + discoveredID = item.ID + break + } + } + if discoveredID == "" { + t.Fatalf("failed to find discovered rule id for %s", discoveredPath) + } + + for name, body := range map[string]string{ + "unknown field": `{"ids":["` + discoveredID + `"],"strategy":"overwrite","extra":true}`, + "trailing json": `{"ids":["` + discoveredID + `"],"strategy":"overwrite"}{"extra":true}`, + } { + t.Run(name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules/collect", strings.NewReader(body)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from collect, got %d: %s", rr.Code, rr.Body.String()) + } + }) + } +} + +func TestManagedRulesCollectDedupesRepeatedIDs(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude") + + discoveredPath := filepath.Join(projectRoot, ".claude", "rules", "seed.md") + if err := os.MkdirAll(filepath.Dir(discoveredPath), 0755); err != nil { + t.Fatalf("failed to create discovered rule dir: %v", err) + } + if err := os.WriteFile(discoveredPath, []byte("# Seed\n"), 0644); err != nil { + t.Fatalf("failed to write discovered rule: %v", err) + } + + discovered, _, err := inspect.ScanRules(projectRoot) + if err != nil { + t.Fatalf("ScanRules() error = %v", err) + } + var discoveredID string + for _, item := range discovered { + if item.Path == discoveredPath { + discoveredID = item.ID + break + } + } + if discoveredID == "" { + t.Fatalf("failed to find discovered rule id for %s", discoveredPath) + } + + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules/collect", strings.NewReader(`{"ids":["`+discoveredID+`","`+discoveredID+`"],"strategy":"overwrite"}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200 from collect, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Created []string `json:"created"` + Overwritten []string `json:"overwritten"` + Skipped []string `json:"skipped"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode collect response: %v", err) + } + if len(resp.Created) != 1 || resp.Created[0] != "claude/seed.md" { + t.Fatalf("collect created = %#v, want one created managed rule", resp.Created) + } + if len(resp.Overwritten) != 0 { + t.Fatalf("collect overwritten = %#v, want none after dedupe", resp.Overwritten) + } + if len(resp.Skipped) != 0 { + t.Fatalf("collect skipped = %#v, want none after dedupe", resp.Skipped) + } +} + +func TestManagedRulesCreateInvokesSharedManagedFamilyValidation(t *testing.T) { + s, _, _, _ := newManagedProjectServer(t, "claude") + + orig := validateManagedRuleSave + defer func() { + validateManagedRuleSave = orig + }() + + called := false + validateManagedRuleSave = func(in managed.RuleInput) error { + called = true + return fmt.Errorf("forced validation failure") + } + + req := httptest.NewRequest(http.MethodPost, "/api/managed/rules", strings.NewReader(`{"tool":"claude","relativePath":"claude/manual.md","content":"# Managed\n"}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if !called { + t.Fatal("expected shared managed rule validator to be called") + } + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 from forced validation failure, got %d: %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "forced validation failure") { + t.Fatalf("response body = %s, want forced validation failure", rr.Body.String()) + } +} diff --git a/internal/server/handler_overview.go b/internal/server/handler_overview.go index 2cab0557..31e8762f 100644 --- a/internal/server/handler_overview.go +++ b/internal/server/handler_overview.go @@ -9,6 +9,8 @@ import ( "skillshare/internal/git" "skillshare/internal/install" "skillshare/internal/resource" + managedhooks "skillshare/internal/resources/hooks" + managedrules "skillshare/internal/resources/rules" "skillshare/internal/sync" "skillshare/internal/utils" versioncheck "skillshare/internal/version" @@ -36,18 +38,16 @@ func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) { isProjectMode := projectRoot != "" - // Count skills skills, err := sync.DiscoverSourceSkills(source) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - // Count top-level source entries (for display) topLevelCount := 0 entries, _ := os.ReadDir(source) - for _, e := range entries { - if e.IsDir() && !utils.IsHidden(e.Name()) { + for _, entry := range entries { + if entry.IsDir() && !utils.IsHidden(entry.Name()) { topLevelCount++ } } @@ -57,10 +57,8 @@ func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) { mode = "merge" } - // Tracked repos trackedRepos := buildTrackedRepos(source, skills) - // Count agents agentCount := 0 if agentsSource != "" { if agents, discoverErr := (resource.AgentKind{}).Discover(agentsSource); discoverErr == nil { @@ -68,16 +66,29 @@ func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) { } } + managedRuleRecords, err := managedrules.NewStore(s.managedRulesProjectRoot()).List() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list managed rules: "+err.Error()) + return + } + managedHookRecords, err := managedhooks.NewStore(s.managedHooksProjectRoot()).List() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list managed hooks: "+err.Error()) + return + } + resp := map[string]any{ - "source": source, - "skillCount": len(skills), - "agentCount": agentCount, - "topLevelCount": topLevelCount, - "targetCount": targetCount, - "mode": mode, - "version": versioncheck.Version, - "trackedRepos": trackedRepos, - "isProjectMode": isProjectMode, + "source": source, + "skillCount": len(skills), + "agentCount": agentCount, + "managedRulesCount": len(managedRuleRecords), + "managedHooksCount": len(managedHookRecords), + "topLevelCount": topLevelCount, + "targetCount": targetCount, + "mode": mode, + "version": versioncheck.Version, + "trackedRepos": trackedRepos, + "isProjectMode": isProjectMode, } if agentsSource != "" { resp["agentsSource"] = agentsSource @@ -102,7 +113,6 @@ func buildTrackedRepos(sourceDir string, skills []sync.DiscoveredSkill) []tracke for _, repoName := range repoNames { repoPath := filepath.Join(sourceDir, repoName) - // Count skills belonging to this repo skillCount := 0 for _, sk := range skills { if sk.IsInRepo && strings.HasPrefix(sk.RelPath, repoName+"/") { @@ -110,7 +120,6 @@ func buildTrackedRepos(sourceDir string, skills []sync.DiscoveredSkill) []tracke } } - // Check git dirty status dirty, _ := git.IsDirty(repoPath) items = append(items, trackedRepoItem{ diff --git a/internal/server/handler_overview_test.go b/internal/server/handler_overview_test.go index 698b0eb2..59c529f5 100644 --- a/internal/server/handler_overview_test.go +++ b/internal/server/handler_overview_test.go @@ -7,6 +7,9 @@ import ( "os" "path/filepath" "testing" + + managedhooks "skillshare/internal/resources/hooks" + managedrules "skillshare/internal/resources/rules" ) func TestHandleOverview_Empty(t *testing.T) { @@ -104,3 +107,53 @@ func TestHandleOverview_ProjectMode(t *testing.T) { t.Errorf("expected projectRoot %q, got %v", tmp, resp["projectRoot"]) } } + +func TestHandleOverview_IncludesManagedResourceCounts(t *testing.T) { + s, projectRoot, sourceDir, _ := newManagedProjectServer(t, "claude") + addSkill(t, sourceDir, "alpha") + + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "claude/manual.md", + Content: []byte("# Managed rule\n"), + }); err != nil { + t.Fatalf("put managed rule: %v", err) + } + + hookStore := managedhooks.NewStore(projectRoot) + if _, err := hookStore.Put(managedhooks.Save{ + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "PreToolUse", + Matcher: "Bash", + Handlers: []managedhooks.Handler{{ + Type: "command", + Command: "./bin/check", + }}, + }); err != nil { + t.Fatalf("put managed hook: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/overview", nil) + rr := httptest.NewRecorder() + s.mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if resp["skillCount"].(float64) != 1 { + t.Fatalf("skillCount = %v, want 1", resp["skillCount"]) + } + if resp["managedRulesCount"].(float64) != 1 { + t.Fatalf("managedRulesCount = %v, want 1", resp["managedRulesCount"]) + } + if resp["managedHooksCount"].(float64) != 1 { + t.Fatalf("managedHooksCount = %v, want 1", resp["managedHooksCount"]) + } +} diff --git a/internal/server/handler_rules.go b/internal/server/handler_rules.go new file mode 100644 index 00000000..fce3816f --- /dev/null +++ b/internal/server/handler_rules.go @@ -0,0 +1,38 @@ +package server + +import ( + "net/http" + + "skillshare/internal/inspect" +) + +type discoveredRuleResponseItem struct { + inspect.RuleItem + Stats contentStats `json:"stats"` +} + +func (s *Server) handleListRules(w http.ResponseWriter, r *http.Request) { + projectRoot := "" + if s.IsProjectMode() { + projectRoot = s.projectRoot + } + + items, warnings, err := inspect.ScanRules(projectRoot) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to scan rules: "+err.Error()) + return + } + + rules := make([]discoveredRuleResponseItem, 0, len(items)) + for _, item := range items { + rules = append(rules, discoveredRuleResponseItem{ + RuleItem: item, + Stats: buildContentStats(item.Content), + }) + } + + writeJSON(w, map[string]any{ + "rules": rules, + "warnings": warnings, + }) +} diff --git a/internal/server/handler_rules_test.go b/internal/server/handler_rules_test.go new file mode 100644 index 00000000..96da5cfc --- /dev/null +++ b/internal/server/handler_rules_test.go @@ -0,0 +1,109 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "skillshare/internal/config" +) + +func TestHandleListRules_Empty(t *testing.T) { + tmp := t.TempDir() + homeDir := filepath.Join(tmp, "home") + t.Setenv("HOME", homeDir) + t.Setenv("XDG_STATE_HOME", filepath.Join(tmp, "state")) + + cfgPath := filepath.Join(tmp, "config", "config.yaml") + t.Setenv("SKILLSHARE_CONFIG", cfgPath) + os.MkdirAll(filepath.Dir(cfgPath), 0755) + os.WriteFile(cfgPath, []byte("source: "+filepath.Join(tmp, "skills")+"\nmode: merge\ntargets: {}\n"), 0644) + + cfg, err := config.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + s := New(cfg, "127.0.0.1:0", "", "") + + req := httptest.NewRequest(http.MethodGet, "/api/rules", nil) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Rules []any `json:"rules"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(resp.Rules) != 0 { + t.Fatalf("expected 0 rules, got %d", len(resp.Rules)) + } +} + +func TestHandleListRules_UsesProjectRoot(t *testing.T) { + tmp := t.TempDir() + homeDir := filepath.Join(tmp, "home") + projectRoot := filepath.Join(tmp, "project") + t.Setenv("HOME", homeDir) + t.Setenv("XDG_STATE_HOME", filepath.Join(tmp, "state")) + + homeRuleDir := filepath.Join(homeDir, ".codex") + os.MkdirAll(homeRuleDir, 0755) + os.WriteFile(filepath.Join(homeRuleDir, "AGENTS.md"), []byte("home rule"), 0644) + + projectRuleDir := filepath.Join(projectRoot, ".codex") + os.MkdirAll(projectRuleDir, 0755) + os.WriteFile(filepath.Join(projectRuleDir, "AGENTS.md"), []byte("project rule"), 0644) + + projectCfgDir := filepath.Join(projectRoot, ".skillshare") + os.MkdirAll(projectCfgDir, 0755) + os.WriteFile(filepath.Join(projectCfgDir, "config.yaml"), []byte("targets: []\n"), 0644) + + cfg := &config.Config{Source: filepath.Join(tmp, "skills"), Targets: map[string]config.TargetConfig{}} + s := NewProject(cfg, &config.ProjectConfig{}, projectRoot, "127.0.0.1:0", "", "") + + req := httptest.NewRequest(http.MethodGet, "/api/rules", nil) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Rules []map[string]any `json:"rules"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(resp.Rules) != 2 { + t.Fatalf("expected 2 rules, got %d", len(resp.Rules)) + } + for _, item := range resp.Rules { + if item["content"] == "project rule" { + stats, ok := item["stats"].(map[string]any) + if !ok { + t.Fatalf("expected stats object on project rule, got %T", item["stats"]) + } + if int(stats["wordCount"].(float64)) != 2 { + t.Fatalf("stats.wordCount = %v, want 2", stats["wordCount"]) + } + if int(stats["lineCount"].(float64)) != 1 { + t.Fatalf("stats.lineCount = %v, want 1", stats["lineCount"]) + } + if int(stats["tokenCount"].(float64)) <= 0 { + t.Fatalf("stats.tokenCount = %v, want > 0", stats["tokenCount"]) + } + return + } + } + t.Fatal("expected project rule content in response") +} diff --git a/internal/server/handler_skills.go b/internal/server/handler_skills.go index 2ca95a65..352301f5 100644 --- a/internal/server/handler_skills.go +++ b/internal/server/handler_skills.go @@ -191,6 +191,7 @@ func (s *Server) handleGetSkill(w http.ResponseWriter, r *http.Request) { if data, err := os.ReadFile(skillMdPath); err == nil { skillMdContent = string(data) } + stats := buildContentStats(skillMdContent) // List all files in the skill directory files := make([]string, 0) @@ -212,7 +213,9 @@ func (s *Server) handleGetSkill(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{ "resource": item, + "skill": item, "skillMdContent": skillMdContent, + "stats": stats, "files": files, }) return diff --git a/internal/server/handler_skills_test.go b/internal/server/handler_skills_test.go index 44452941..a9a024da 100644 --- a/internal/server/handler_skills_test.go +++ b/internal/server/handler_skills_test.go @@ -72,6 +72,42 @@ func TestHandleGetSkill_Found(t *testing.T) { if res["flatName"] != "my-skill" { t.Errorf("expected flatName 'my-skill', got %v", res["flatName"]) } + + skillMdContent, _ := resp["skillMdContent"].(string) + stats, ok := resp["stats"].(map[string]any) + if !ok { + t.Fatalf("expected stats object in response, got %T", resp["stats"]) + } + + wordCount, ok := stats["wordCount"].(float64) + if !ok { + t.Fatalf("expected numeric stats.wordCount, got %T", stats["wordCount"]) + } + lineCount, ok := stats["lineCount"].(float64) + if !ok { + t.Fatalf("expected numeric stats.lineCount, got %T", stats["lineCount"]) + } + tokenCount, ok := stats["tokenCount"].(float64) + if !ok { + t.Fatalf("expected numeric stats.tokenCount, got %T", stats["tokenCount"]) + } + + trimmed := strings.TrimSpace(skillMdContent) + wantWords := 0 + wantLines := 0 + if trimmed != "" { + wantWords = len(strings.Fields(trimmed)) + wantLines = len(strings.Split(strings.ReplaceAll(trimmed, "\r\n", "\n"), "\n")) + } + if int(wordCount) != wantWords { + t.Fatalf("stats.wordCount = %d, want %d", int(wordCount), wantWords) + } + if int(lineCount) != wantLines { + t.Fatalf("stats.lineCount = %d, want %d", int(lineCount), wantLines) + } + if int(tokenCount) <= 0 { + t.Fatalf("stats.tokenCount = %d, want > 0 for non-empty SKILL.md", int(tokenCount)) + } } func TestHandleGetSkill_NotFound(t *testing.T) { @@ -104,6 +140,47 @@ func TestHandleGetSkillFile_PathTraversal(t *testing.T) { } } +func TestHandleGetSkill_StatsTokenizerCompatibility(t *testing.T) { + s, src := newTestServer(t) + + skillDir := filepath.Join(src, "token-skill") + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatalf("failed to create skill directory: %v", err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("tiktoken is great!"), 0644); err != nil { + t.Fatalf("failed to write SKILL.md: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/skills/token-skill", nil) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Stats struct { + TokenCount int `json:"tokenCount"` + WordCount int `json:"wordCount"` + LineCount int `json:"lineCount"` + } `json:"stats"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.Stats.WordCount != 3 { + t.Fatalf("stats.wordCount = %d, want 3", resp.Stats.WordCount) + } + if resp.Stats.LineCount != 1 { + t.Fatalf("stats.lineCount = %d, want 1", resp.Stats.LineCount) + } + if resp.Stats.TokenCount != 6 { + t.Fatalf("stats.tokenCount = %d, want 6", resp.Stats.TokenCount) + } +} + func TestHandleUninstallRepo_NestedRepoPath(t *testing.T) { s, src := newTestServer(t) addTrackedRepo(t, src, filepath.Join("org", "_team-skills")) diff --git a/internal/server/handler_sync.go b/internal/server/handler_sync.go index 611f31b3..63e299f3 100644 --- a/internal/server/handler_sync.go +++ b/internal/server/handler_sync.go @@ -2,6 +2,7 @@ package server import ( "encoding/json" + "io" "maps" "net/http" "os" @@ -35,6 +36,7 @@ func ignorePayload(stats *skillignore.IgnoreStats) map[string]any { } type syncTargetResult struct { + Resource string `json:"resource"` Target string `json:"target"` Linked []string `json:"linked"` Updated []string `json:"updated"` @@ -49,68 +51,78 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { defer s.mu.Unlock() var body struct { - DryRun bool `json:"dryRun"` - Force bool `json:"force"` - Kind string `json:"kind"` + DryRun bool `json:"dryRun"` + Force bool `json:"force"` + Kind string `json:"kind"` + Resources []string `json:"resources"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - // Default to non-dry-run, non-force, empty kind (both) + if err != io.EOF { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } } if body.Kind != "" && body.Kind != kindSkill && body.Kind != kindAgent { writeError(w, http.StatusBadRequest, "invalid kind: must be 'skill', 'agent', or empty") return } + if body.Kind != "" && len(body.Resources) > 0 { + writeError(w, http.StatusBadRequest, "kind and resources cannot be combined") + return + } + + var resources serverSyncResources + switch body.Kind { + case kindAgent: + resources = serverSyncResources{} + case kindSkill: + resources = serverSyncResources{skills: true} + default: + parsed, err := parseServerSyncResources(body.Resources) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + resources = parsed + } globalMode := s.cfg.Mode if globalMode == "" { globalMode = "merge" } - // Pre-check warnings via shared config validation - warnings, validErr := config.ValidateConfig(s.cfg) - if validErr != nil { - writeError(w, http.StatusBadRequest, validErr.Error()) - return + warnings := []string{} + if resources.skills || body.Kind == kindAgent { + configWarnings, validErr := config.ValidateConfig(s.cfg) + if validErr != nil { + writeError(w, http.StatusBadRequest, validErr.Error()) + return + } + warnings = append(warnings, configWarnings...) } results := make([]syncTargetResult, 0) - var ignoreStats *skillignore.IgnoreStats + var allSkills []ssync.DiscoveredSkill - // Skill sync (skip when kind == "agent") - if body.Kind != kindAgent { - var allSkills []ssync.DiscoveredSkill + if resources.skills { var err error allSkills, ignoreStats, err = ssync.DiscoverSourceSkillsWithStats(s.cfg.Source) if err != nil { writeError(w, http.StatusInternalServerError, "failed to discover skills: "+err.Error()) return } - if len(allSkills) == 0 { warnings = append(warnings, "source directory is empty (0 skills)") } + } - // Registry entries are managed by install/uninstall, not sync. - // Sync only manages symlinks — it must not prune registry entries - // for installed skills whose files may be missing from disk. - + if body.Kind == kindAgent { + s.syncAgentsForUI(&results, &warnings, body.DryRun, body.Force) + } else { + allTargets := s.cloneTargets() for name, target := range s.cfg.Targets { - sc := target.SkillsConfig() - mode := sc.Mode - if mode == "" { - mode = globalMode - } - - res := syncTargetResult{ - Target: name, - Linked: make([]string, 0), - Updated: make([]string, 0), - Skipped: make([]string, 0), - Pruned: make([]string, 0), - } - syncErrArgs := map[string]any{ "targets_total": len(s.cfg.Targets), "targets_failed": 1, @@ -120,130 +132,91 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { "scope": "ui", } - switch mode { - case "merge": - mergeResult, err := ssync.SyncTargetMergeWithSkills(name, target, allSkills, s.cfg.Source, body.DryRun, body.Force, s.projectRoot) - if err != nil { - s.writeOpsLog("sync", "error", start, syncErrArgs, err.Error()) - writeError(w, http.StatusInternalServerError, "sync failed for "+name+": "+err.Error()) - return - } - res.Linked = mergeResult.Linked - res.Updated = mergeResult.Updated - res.Skipped = mergeResult.Skipped - res.DirCreated = mergeResult.DirCreated - - pruneResult, err := ssync.PruneOrphanLinksWithSkills(ssync.PruneOptions{ - TargetPath: sc.Path, SourcePath: s.cfg.Source, Skills: allSkills, - Include: sc.Include, Exclude: sc.Exclude, TargetNaming: sc.TargetNaming, TargetName: name, - DryRun: body.DryRun, Force: body.Force, - }) - if err == nil { - res.Pruned = pruneResult.Removed + if resources.skills { + sc := target.SkillsConfig() + mode := sc.Mode + if mode == "" { + mode = globalMode } - case "copy": - copyResult, err := ssync.SyncTargetCopyWithSkills(name, target, allSkills, s.cfg.Source, body.DryRun, body.Force, nil) - if err != nil { - s.writeOpsLog("sync", "error", start, syncErrArgs, err.Error()) - writeError(w, http.StatusInternalServerError, "sync failed for "+name+": "+err.Error()) - return - } - res.Linked = copyResult.Copied - res.Updated = copyResult.Updated - res.Skipped = copyResult.Skipped - res.DirCreated = copyResult.DirCreated - - pruneResult, err := ssync.PruneOrphanCopiesWithSkills(sc.Path, allSkills, sc.Include, sc.Exclude, name, sc.TargetNaming, body.DryRun) - if err == nil { - res.Pruned = pruneResult.Removed + res := newSyncTargetResult(name, "skills") + switch mode { + case "merge": + mergeResult, err := ssync.SyncTargetMergeWithSkills(name, target, allSkills, s.cfg.Source, body.DryRun, body.Force, s.projectRoot) + if err != nil { + s.writeOpsLog("sync", "error", start, syncErrArgs, err.Error()) + writeError(w, http.StatusInternalServerError, "sync failed for "+name+": "+err.Error()) + return + } + res.Linked = mergeResult.Linked + res.Updated = mergeResult.Updated + res.Skipped = mergeResult.Skipped + res.DirCreated = mergeResult.DirCreated + + pruneResult, err := ssync.PruneOrphanLinksWithSkills(ssync.PruneOptions{ + TargetPath: sc.Path, SourcePath: s.cfg.Source, Skills: allSkills, + Include: sc.Include, Exclude: sc.Exclude, TargetNaming: sc.TargetNaming, TargetName: name, + DryRun: body.DryRun, Force: body.Force, + }) + if err == nil { + res.Pruned = pruneResult.Removed + } + + case "copy": + copyResult, err := ssync.SyncTargetCopyWithSkills(name, target, allSkills, s.cfg.Source, body.DryRun, body.Force, nil) + if err != nil { + s.writeOpsLog("sync", "error", start, syncErrArgs, err.Error()) + writeError(w, http.StatusInternalServerError, "sync failed for "+name+": "+err.Error()) + return + } + res.Linked = copyResult.Copied + res.Updated = copyResult.Updated + res.Skipped = copyResult.Skipped + res.DirCreated = copyResult.DirCreated + + pruneResult, err := ssync.PruneOrphanCopiesWithSkills(sc.Path, allSkills, sc.Include, sc.Exclude, name, sc.TargetNaming, body.DryRun) + if err == nil { + res.Pruned = pruneResult.Removed + } + + default: + err := ssync.SyncTarget(name, target, s.cfg.Source, body.DryRun, s.projectRoot) + if err != nil { + s.writeOpsLog("sync", "error", start, syncErrArgs, err.Error()) + writeError(w, http.StatusInternalServerError, "sync failed for "+name+": "+err.Error()) + return + } + res.Linked = []string{"(symlink mode)"} } + results = append(results, res) + } - default: - err := ssync.SyncTarget(name, target, s.cfg.Source, body.DryRun, s.projectRoot) + if resources.rules || resources.hooks { + rows, err := s.syncManagedResourcesForTarget(name, target, allTargets, resources, body.DryRun) + results = append(results, rows...) if err != nil { s.writeOpsLog("sync", "error", start, syncErrArgs, err.Error()) writeError(w, http.StatusInternalServerError, "sync failed for "+name+": "+err.Error()) return } - res.Linked = []string{"(symlink mode)"} } - - results = append(results, res) } } - // Agent sync (skip when kind == "skill") - if body.Kind != kindSkill { - agentsSource := s.agentsSource() - if info, err := os.Stat(agentsSource); err == nil && info.IsDir() { - agents := discoverActiveAgents(agentsSource) - builtinAgents := s.builtinAgentTargets() - - for name, target := range s.cfg.Targets { - agentPath := resolveAgentPath(target, builtinAgents, name) - if agentPath == "" { - continue - } - - agentMode := target.AgentsConfig().Mode - if agentMode == "" { - agentMode = "merge" - } - - agentResult, err := ssync.SyncAgents(agents, agentsSource, agentPath, agentMode, body.DryRun, body.Force) - if err != nil { - warnings = append(warnings, "agent sync failed for "+name+": "+err.Error()) - continue - } - - // Prune orphan agents even when the source is empty so uninstall-all - // matches skills and clears previously synced target entries. - var pruned []string - if agentMode == "merge" { - pruned, _ = ssync.PruneOrphanAgentLinks(agentPath, agents, body.DryRun) - } else if agentMode == "copy" { - pruned, _ = ssync.PruneOrphanAgentCopies(agentPath, agents, body.DryRun) - } - - // Find or create result entry for this target - idx := -1 - for i := range results { - if results[i].Target == name { - idx = i - break - } - } - if idx < 0 && (len(agentResult.Linked) > 0 || len(agentResult.Updated) > 0 || len(agentResult.Skipped) > 0 || len(pruned) > 0) { - results = append(results, syncTargetResult{ - Target: name, - Linked: make([]string, 0), - Updated: make([]string, 0), - Skipped: make([]string, 0), - Pruned: make([]string, 0), - }) - idx = len(results) - 1 - } - - if idx >= 0 { - results[idx].Linked = append(results[idx].Linked, agentResult.Linked...) - results[idx].Updated = append(results[idx].Updated, agentResult.Updated...) - results[idx].Skipped = append(results[idx].Skipped, agentResult.Skipped...) - results[idx].Pruned = append(results[idx].Pruned, pruned...) - } - } - } - } - - // Log the sync operation - s.writeOpsLog("sync", "ok", start, map[string]any{ - "targets_total": len(results), + args := map[string]any{ + "targets_total": len(s.cfg.Targets), "targets_failed": 0, "dry_run": body.DryRun, "force": body.Force, - "kind": body.Kind, "scope": "ui", - }, "") + } + if body.Kind != "" { + args["kind"] = body.Kind + } + if body.Kind == "" { + args["resources"] = body.Resources + } + s.writeOpsLog("sync", "ok", start, args, "") resp := map[string]any{ "results": results, @@ -254,6 +227,64 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { writeJSON(w, resp) } +func (s *Server) syncAgentsForUI(results *[]syncTargetResult, warnings *[]string, dryRun, force bool) { + agentsSource := s.agentsSource() + info, err := os.Stat(agentsSource) + if err != nil || !info.IsDir() { + return + } + + agents := discoverActiveAgents(agentsSource) + builtinAgents := s.builtinAgentTargets() + + for name, target := range s.cfg.Targets { + agentPath := resolveAgentPath(target, builtinAgents, name) + if agentPath == "" { + continue + } + + agentMode := target.AgentsConfig().Mode + if agentMode == "" { + agentMode = "merge" + } + + agentResult, err := ssync.SyncAgents(agents, agentsSource, agentPath, agentMode, dryRun, force) + if err != nil { + *warnings = append(*warnings, "agent sync failed for "+name+": "+err.Error()) + continue + } + + var pruned []string + if agentMode == "merge" { + pruned, _ = ssync.PruneOrphanAgentLinks(agentPath, agents, dryRun) + } else if agentMode == "copy" { + pruned, _ = ssync.PruneOrphanAgentCopies(agentPath, agents, dryRun) + } + + if len(agentResult.Linked) == 0 && len(agentResult.Updated) == 0 && len(agentResult.Skipped) == 0 && len(pruned) == 0 { + continue + } + + res := newSyncTargetResult(name, "agents") + res.Linked = append(res.Linked, agentResult.Linked...) + res.Updated = append(res.Updated, agentResult.Updated...) + res.Skipped = append(res.Skipped, agentResult.Skipped...) + res.Pruned = append(res.Pruned, pruned...) + *results = append(*results, res) + } +} + +func newSyncTargetResult(target, resource string) syncTargetResult { + return syncTargetResult{ + Resource: resource, + Target: target, + Linked: make([]string, 0), + Updated: make([]string, 0), + Skipped: make([]string, 0), + Pruned: make([]string, 0), + } +} + type diffItem struct { Skill string `json:"skill"` Action string `json:"action"` // "link", "update", "skip", "prune", "local" diff --git a/internal/server/handler_sync_test.go b/internal/server/handler_sync_test.go index db32f015..0bc00754 100644 --- a/internal/server/handler_sync_test.go +++ b/internal/server/handler_sync_test.go @@ -11,15 +11,16 @@ import ( "skillshare/internal/config" "skillshare/internal/install" + managedhooks "skillshare/internal/resources/hooks" + managedrules "skillshare/internal/resources/rules" ) -func TestHandleSync_MergeMode(t *testing.T) { +func TestHandleSync_DefaultSyncIncludesManagedResourceResults(t *testing.T) { tgtPath := filepath.Join(t.TempDir(), "claude-skills") s, src := newTestServerWithTargets(t, map[string]string{"claude": tgtPath}) addSkill(t, src, "alpha") - body := `{"dryRun":false}` - req := httptest.NewRequest(http.MethodPost, "/api/sync", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/sync", strings.NewReader(`{"dryRun":false}`)) rr := httptest.NewRecorder() s.handler.ServeHTTP(rr, req) @@ -30,32 +31,34 @@ func TestHandleSync_MergeMode(t *testing.T) { var resp struct { Results []map[string]any `json:"results"` } - json.Unmarshal(rr.Body.Bytes(), &resp) - if len(resp.Results) != 1 { - t.Fatalf("expected 1 sync result, got %d", len(resp.Results)) + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp.Results) != 3 { + t.Fatalf("expected 3 sync results, got %d", len(resp.Results)) } if resp.Results[0]["target"] != "claude" { t.Errorf("expected target 'claude', got %v", resp.Results[0]["target"]) } + if resp.Results[0]["resource"] != "skills" { + t.Errorf("expected first resource to be skills, got %v", resp.Results[0]["resource"]) + } } func TestHandleSync_IgnoredSkillNotPrunedFromRegistry(t *testing.T) { tgtPath := filepath.Join(t.TempDir(), "claude-skills") s, src := newTestServerWithTargets(t, map[string]string{"claude": tgtPath}) - // Create a skill with install metadata (so it appears in registry) addSkill(t, src, "kept-skill") addSkillMeta(t, src, "kept-skill", "github.com/user/kept") - // Create another skill that will be ignored addSkill(t, src, "ignored-skill") addSkillMeta(t, src, "ignored-skill", "github.com/user/ignored") - // Add .skillignore to exclude the second skill - os.WriteFile(filepath.Join(src, ".skillignore"), []byte("ignored-skill\n"), 0644) + if err := os.WriteFile(filepath.Join(src, ".skillignore"), []byte("ignored-skill\n"), 0o644); err != nil { + t.Fatalf("write .skillignore: %v", err) + } - // Pre-populate store with both entries and persist to disk - // (server auto-reloads metadata from disk on each request) s.skillsStore = install.NewMetadataStore() s.skillsStore.Set("kept-skill", &install.MetadataEntry{Source: "github.com/user/kept"}) s.skillsStore.Set("ignored-skill", &install.MetadataEntry{Source: "github.com/user/ignored"}) @@ -63,9 +66,7 @@ func TestHandleSync_IgnoredSkillNotPrunedFromRegistry(t *testing.T) { t.Fatalf("failed to save metadata: %v", err) } - // Run sync (non-dry-run) - body := `{"dryRun":false}` - req := httptest.NewRequest(http.MethodPost, "/api/sync", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/sync", strings.NewReader(`{"dryRun":false,"kind":"skill"}`)) rr := httptest.NewRecorder() s.handler.ServeHTTP(rr, req) @@ -73,7 +74,6 @@ func TestHandleSync_IgnoredSkillNotPrunedFromRegistry(t *testing.T) { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } - // Both entries should survive — ignored skill still exists on disk names := s.skillsStore.List() if len(names) != 2 { t.Fatalf("expected 2 metadata entries after sync, got %d: %v", len(names), names) @@ -81,10 +81,9 @@ func TestHandleSync_IgnoredSkillNotPrunedFromRegistry(t *testing.T) { } func TestHandleSync_NoTargets(t *testing.T) { - s, _ := newTestServer(t) // no targets configured + s, _ := newTestServer(t) - body := `{}` - req := httptest.NewRequest(http.MethodPost, "/api/sync", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/sync", strings.NewReader(`{}`)) rr := httptest.NewRecorder() s.handler.ServeHTTP(rr, req) @@ -95,7 +94,9 @@ func TestHandleSync_NoTargets(t *testing.T) { var resp struct { Results []any `json:"results"` } - json.Unmarshal(rr.Body.Bytes(), &resp) + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } if len(resp.Results) != 0 { t.Errorf("expected 0 results for no targets, got %d", len(resp.Results)) } @@ -112,6 +113,7 @@ func TestHandleSync_AgentPrunesOrphanWhenSourceEmpty(t *testing.T) { if err := os.MkdirAll(agentTarget, 0o755); err != nil { t.Fatalf("mkdir agent target: %v", err) } + orphanPath := filepath.Join(agentTarget, "tutor.md") if err := os.Symlink(filepath.Join(agentSource, "tutor.md"), orphanPath); err != nil { t.Fatalf("seed orphan agent symlink: %v", err) @@ -140,8 +142,9 @@ func TestHandleSync_AgentPrunesOrphanWhenSourceEmpty(t *testing.T) { var resp struct { Results []struct { - Target string `json:"target"` - Pruned []string `json:"pruned"` + Target string `json:"target"` + Resource string `json:"resource"` + Pruned []string `json:"pruned"` } `json:"results"` } if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { @@ -154,7 +157,259 @@ func TestHandleSync_AgentPrunesOrphanWhenSourceEmpty(t *testing.T) { if resp.Results[0].Target != "claude" { t.Fatalf("expected claude target, got %q", resp.Results[0].Target) } + if resp.Results[0].Resource != "agents" { + t.Fatalf("expected agents resource, got %q", resp.Results[0].Resource) + } if len(resp.Results[0].Pruned) != 1 || resp.Results[0].Pruned[0] != "tutor.md" { t.Fatalf("expected pruned tutor.md, got %+v", resp.Results[0].Pruned) } } + +func TestHandleSync_InvalidJSONReturnsBadRequest(t *testing.T) { + tgtPath := filepath.Join(t.TempDir(), "claude-skills") + s, src := newTestServerWithTargets(t, map[string]string{"claude": tgtPath}) + addSkill(t, src, "alpha") + + req := httptest.NewRequest(http.MethodPost, "/api/sync", strings.NewReader(`{"dryRun":`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) + } + + if _, err := os.Lstat(filepath.Join(tgtPath, "alpha")); !os.IsNotExist(err) { + t.Fatalf("expected no sync side effects on invalid JSON, got err=%v", err) + } +} + +func TestHandleSync_ManagedResourcesDoNotRequireSkillSource(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude") + s.cfg.Source = filepath.Join(t.TempDir(), "missing-source") + + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "claude/manual.md", + Content: []byte("# Managed rule\n"), + }); err != nil { + t.Fatalf("put managed rule: %v", err) + } + + hookStore := managedhooks.NewStore(projectRoot) + if _, err := hookStore.Put(managedhooks.Save{ + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "PreToolUse", + Matcher: "Bash", + Handlers: []managedhooks.Handler{{ + Type: "command", + Command: "./bin/check", + }}, + }); err != nil { + t.Fatalf("put managed hook: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/sync", strings.NewReader(`{"resources":["rules","hooks"]}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + if _, err := os.Stat(filepath.Join(projectRoot, ".claude", "rules", "manual.md")); err != nil { + t.Fatalf("expected managed rule to sync: %v", err) + } + if _, err := os.Stat(filepath.Join(projectRoot, ".claude", "settings.json")); err != nil { + t.Fatalf("expected managed hook config to sync: %v", err) + } +} + +func TestHandleSync_ManagedRuleFailureStillAttemptsHooks(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude") + + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "claude/manual.md", + Content: []byte("# Managed rule\n"), + }); err != nil { + t.Fatalf("put managed rule: %v", err) + } + + hookStore := managedhooks.NewStore(projectRoot) + if _, err := hookStore.Put(managedhooks.Save{ + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "PreToolUse", + Matcher: "Bash", + Handlers: []managedhooks.Handler{{ + Type: "command", + Command: "./bin/check", + }}, + }); err != nil { + t.Fatalf("put managed hook: %v", err) + } + + if err := os.MkdirAll(filepath.Join(projectRoot, ".claude"), 0o755); err != nil { + t.Fatalf("mkdir .claude: %v", err) + } + if err := os.WriteFile(filepath.Join(projectRoot, ".claude", "rules"), []byte("not-a-directory"), 0o644); err != nil { + t.Fatalf("seed invalid rules path: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/sync", strings.NewReader(`{"resources":["rules","hooks"]}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", rr.Code, rr.Body.String()) + } + + if _, err := os.Stat(filepath.Join(projectRoot, ".claude", "settings.json")); err != nil { + t.Fatalf("expected hooks sync to continue after rules failure: %v", err) + } +} + +func TestHandleSync_DefaultsToAllManagedResources(t *testing.T) { + s, projectRoot, sourceDir, _ := newManagedProjectServer(t, "claude") + addSkill(t, sourceDir, "alpha") + + ruleStore := managedrules.NewStore(projectRoot) + if _, err := ruleStore.Put(managedrules.Save{ + ID: "claude/manual.md", + Content: []byte("# Managed rule\n"), + }); err != nil { + t.Fatalf("put managed rule: %v", err) + } + + hookStore := managedhooks.NewStore(projectRoot) + if _, err := hookStore.Put(managedhooks.Save{ + ID: "claude/pre-tool-use/bash.yaml", + Tool: "claude", + Event: "PreToolUse", + Matcher: "Bash", + Handlers: []managedhooks.Handler{{ + Type: "command", + Command: "./bin/check", + }}, + }); err != nil { + t.Fatalf("put managed hook: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/sync", strings.NewReader(`{"dryRun":false}`)) + rr := httptest.NewRecorder() + s.mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Results []struct { + Target string `json:"target"` + Resource string `json:"resource"` + Linked []string `json:"linked"` + Updated []string `json:"updated"` + Skipped []string `json:"skipped"` + Pruned []string `json:"pruned"` + } `json:"results"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if len(resp.Results) != 3 { + t.Fatalf("expected 3 sync results, got %d: %#v", len(resp.Results), resp.Results) + } + + byResource := make(map[string]struct { + Linked []string + Updated []string + Skipped []string + Pruned []string + }, len(resp.Results)) + for _, result := range resp.Results { + if result.Target != "claude" { + t.Fatalf("result target = %q, want claude", result.Target) + } + byResource[result.Resource] = struct { + Linked []string + Updated []string + Skipped []string + Pruned []string + }{ + Linked: result.Linked, + Updated: result.Updated, + Skipped: result.Skipped, + Pruned: result.Pruned, + } + } + + if got := byResource["skills"].Linked; len(got) != 1 || got[0] != "alpha" { + t.Fatalf("skills linked = %#v, want [alpha]", got) + } + if got := byResource["rules"].Updated; len(got) != 1 || got[0] != filepath.Join(projectRoot, ".claude", "rules", "manual.md") { + t.Fatalf("rules updated = %#v, want compiled rule path", got) + } + if _, err := os.Stat(filepath.Join(projectRoot, ".claude", "rules", "manual.md")); err != nil { + t.Fatalf("expected synced rule file: %v", err) + } + if got := byResource["hooks"].Updated; len(got) != 1 || got[0] != filepath.Join(projectRoot, ".claude", "settings.json") { + t.Fatalf("hooks updated = %#v, want compiled hook path", got) + } + if _, err := os.Stat(filepath.Join(projectRoot, ".claude", "settings.json")); err != nil { + t.Fatalf("expected synced hook file: %v", err) + } +} + +func TestHandleSync_HooksOnlyMaterializesEmptyCarrier(t *testing.T) { + s, projectRoot, _, _ := newManagedProjectServer(t, "claude") + + settingsPath := filepath.Join(projectRoot, ".claude", "settings.json") + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil { + t.Fatalf("mkdir settings dir: %v", err) + } + if err := os.WriteFile(settingsPath, []byte(`{"model":"sonnet"}`), 0o644); err != nil { + t.Fatalf("write settings.json: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/sync", strings.NewReader(`{"resources":["hooks"]}`)) + rr := httptest.NewRecorder() + s.mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Results []struct { + Target string `json:"target"` + Resource string `json:"resource"` + Updated []string `json:"updated"` + } `json:"results"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp.Results) != 1 { + t.Fatalf("expected 1 result, got %d: %#v", len(resp.Results), resp.Results) + } + if resp.Results[0].Target != "claude" || resp.Results[0].Resource != "hooks" { + t.Fatalf("result = %#v, want hooks result for claude", resp.Results[0]) + } + if len(resp.Results[0].Updated) != 1 || resp.Results[0].Updated[0] != settingsPath { + t.Fatalf("updated = %#v, want %q", resp.Results[0].Updated, settingsPath) + } + + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("read settings.json: %v", err) + } + content := string(data) + if !strings.Contains(content, `"model":"sonnet"`) { + t.Fatalf("settings.json = %q, want existing key preserved", content) + } + if !strings.Contains(content, `"hooks":{}`) { + t.Fatalf("settings.json = %q, want empty hooks carrier", content) + } +} diff --git a/internal/server/logging_test.go b/internal/server/logging_test.go index ffddf388..d2dd8752 100644 --- a/internal/server/logging_test.go +++ b/internal/server/logging_test.go @@ -346,3 +346,34 @@ func TestHandleInstall_ErrorAlsoWritesInstallLog(t *testing.T) { t.Fatalf("expected error message to mention existing skill, got %q", e.Message) } } + +func TestHandleSync_WritesActualTargetCountInOpsLog(t *testing.T) { + s, src := newTestServerWithTargets(t, map[string]string{ + "claude": filepath.Join(t.TempDir(), "claude-skills"), + }) + addSkill(t, src, "alpha") + + req := httptest.NewRequest(http.MethodPost, "/api/sync", strings.NewReader(`{}`)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("unexpected status: got %d, body=%s", rr.Code, rr.Body.String()) + } + + entries, err := oplog.Read(config.ConfigPath(), oplog.OpsFile, 10) + if err != nil { + t.Fatalf("failed to read ops log: %v", err) + } + if len(entries) == 0 { + t.Fatal("expected at least one operations log entry") + } + + e := entries[0] + if e.Command != "sync" { + t.Fatalf("expected latest command to be sync, got %q", e.Command) + } + if got := e.Args["targets_total"]; got != float64(1) && got != 1 { + t.Fatalf("expected targets_total=1, got %#v", got) + } +} diff --git a/internal/server/managed_resource_sync.go b/internal/server/managed_resource_sync.go new file mode 100644 index 00000000..9068801f --- /dev/null +++ b/internal/server/managed_resource_sync.go @@ -0,0 +1,110 @@ +package server + +import ( + "errors" + "fmt" + "sort" + "strings" + + "skillshare/internal/config" + managed "skillshare/internal/resources/managed" +) + +type serverSyncResources struct { + skills bool + rules bool + hooks bool +} + +func defaultServerSyncResources() serverSyncResources { + return serverSyncResources{ + skills: true, + rules: true, + hooks: true, + } +} + +func parseServerSyncResources(values []string) (serverSyncResources, error) { + if len(values) == 0 { + return defaultServerSyncResources(), nil + } + + var resources serverSyncResources + for _, raw := range values { + for _, part := range strings.Split(raw, ",") { + switch strings.ToLower(strings.TrimSpace(part)) { + case "": + continue + case "skills": + resources.skills = true + case "rules": + resources.rules = true + case "hooks": + resources.hooks = true + default: + return serverSyncResources{}, fmt.Errorf("unsupported sync resource %q", strings.TrimSpace(part)) + } + } + } + + if !resources.skills && !resources.rules && !resources.hooks { + return serverSyncResources{}, errors.New("at least one sync resource is required") + } + + return resources, nil +} + +func (s *Server) syncManagedResourcesForTarget(name string, target config.TargetConfig, allTargets map[string]config.TargetConfig, resources serverSyncResources, dryRun bool) ([]syncTargetResult, error) { + rows := managed.Sync(managed.SyncRequest{ + ProjectRoot: s.managedRulesProjectRoot(), + DryRun: dryRun, + Resources: managed.ResourceSet{ + Rules: resources.rules, + Hooks: resources.hooks, + }, + Targets: []managed.TargetSyncSpec{{ + Name: name, + Target: target, + }}, + AllTargets: managedTargetSpecsFromMap(allTargets), + }) + return syncTargetResultsFromManagedRows(name, rows) +} + +func managedTargetSpecsFromMap(targets map[string]config.TargetConfig) []managed.TargetSyncSpec { + names := make([]string, 0, len(targets)) + for name := range targets { + names = append(names, name) + } + sort.Strings(names) + + specs := make([]managed.TargetSyncSpec, 0, len(names)) + for _, name := range names { + specs = append(specs, managed.TargetSyncSpec{ + Name: name, + Target: targets[name], + }) + } + return specs +} + +func syncTargetResultsFromManagedRows(target string, rows []managed.SyncResult) ([]syncTargetResult, error) { + results := make([]syncTargetResult, 0, len(rows)) + var errs []string + + for _, row := range rows { + result := newSyncTargetResult(target, row.Resource) + result.Updated = append(result.Updated, row.Updated...) + result.Skipped = append(result.Skipped, row.Skipped...) + result.Pruned = append(result.Pruned, row.Pruned...) + results = append(results, result) + if row.Err != nil { + errs = append(errs, row.Err.Error()) + } + } + + if len(errs) > 0 { + return results, errors.New(strings.Join(errs, "; ")) + } + return results, nil +} diff --git a/internal/server/server.go b/internal/server/server.go index c48d1188..8119ca50 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -370,6 +370,41 @@ func (s *Server) registerRoutes() { s.mux.HandleFunc("POST /api/resources/batch/targets", s.handleBatchSetTargets) s.mux.HandleFunc("PATCH /api/resources/{name}/targets", s.handleSetSkillTargets) + // Legacy skills aliases + s.mux.HandleFunc("GET /api/skills", s.handleListSkills) + s.mux.HandleFunc("GET /api/skills/templates", s.handleGetTemplates) + s.mux.HandleFunc("POST /api/skills", s.handleCreateSkill) + s.mux.HandleFunc("GET /api/skills/{name}", s.handleGetSkill) + s.mux.HandleFunc("GET /api/skills/{name}/files/{filepath...}", s.handleGetSkillFile) + s.mux.HandleFunc("POST /api/skills/{name}/disable", s.handleDisableSkill) + s.mux.HandleFunc("POST /api/skills/{name}/enable", s.handleEnableSkill) + s.mux.HandleFunc("DELETE /api/skills/{name}", s.handleUninstallSkill) + s.mux.HandleFunc("POST /api/skills/batch/targets", s.handleBatchSetTargets) + s.mux.HandleFunc("PATCH /api/skills/{name}/targets", s.handleSetSkillTargets) + + // Rules and hooks + s.mux.HandleFunc("GET /api/rules", s.handleListRules) + s.mux.HandleFunc("GET /api/hooks", s.handleListHooks) + s.mux.HandleFunc("GET /api/managed/capabilities", s.handleManagedCapabilities) + s.mux.HandleFunc("GET /api/managed/rules", s.handleListManagedRules) + s.mux.HandleFunc("POST /api/managed/rules", s.handleCreateManagedRule) + s.mux.HandleFunc("GET /api/managed/rules/diff", s.handleDiffManagedRules) + s.mux.HandleFunc("POST /api/managed/rules/collect", s.handleCollectManagedRules) + s.mux.HandleFunc("GET /api/managed/rules/{id...}", s.handleGetManagedRule) + s.mux.HandleFunc("PUT /api/managed/rules/{id...}", s.handleUpdateManagedRule) + s.mux.HandleFunc("PATCH /api/managed/rules/{id}/targets", s.handleSetManagedRuleTargets) + s.mux.HandleFunc("PATCH /api/managed/rules/{id}/disabled", s.handleSetManagedRuleDisabled) + s.mux.HandleFunc("DELETE /api/managed/rules/{id...}", s.handleDeleteManagedRule) + s.mux.HandleFunc("GET /api/managed/hooks", s.handleListManagedHooks) + s.mux.HandleFunc("POST /api/managed/hooks", s.handleCreateManagedHook) + s.mux.HandleFunc("GET /api/managed/hooks/diff", s.handleDiffManagedHooks) + s.mux.HandleFunc("POST /api/managed/hooks/collect", s.handleCollectManagedHooks) + s.mux.HandleFunc("GET /api/managed/hooks/{id...}", s.handleGetManagedHook) + s.mux.HandleFunc("PUT /api/managed/hooks/{id...}", s.handleUpdateManagedHook) + s.mux.HandleFunc("PATCH /api/managed/hooks/{id}/targets", s.handleSetManagedHookTargets) + s.mux.HandleFunc("PATCH /api/managed/hooks/{id}/disabled", s.handleSetManagedHookDisabled) + s.mux.HandleFunc("DELETE /api/managed/hooks/{id...}", s.handleDeleteManagedHook) + // Targets s.mux.HandleFunc("GET /api/targets", s.handleListTargets) s.mux.HandleFunc("POST /api/targets", s.handleAddTarget) diff --git a/ui/e2e/managed-rules-hooks.spec.ts b/ui/e2e/managed-rules-hooks.spec.ts new file mode 100644 index 00000000..bf6f41a0 --- /dev/null +++ b/ui/e2e/managed-rules-hooks.spec.ts @@ -0,0 +1,404 @@ +import { expect, test, type Page } from '@playwright/test'; + +async function installBrowserMocks(page: Page) { + await page.addInitScript(() => { + const originalFetch = window.fetch.bind(window); + + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); + + const overview = { + source: '/tmp/home', + skillCount: 4, + topLevelCount: 2, + targetCount: 3, + mode: 'project', + version: '1.0.0', + managedRulesCount: 1, + managedHooksCount: 1, + trackedRepos: [], + isProjectMode: true, + }; + + const managedHookDetail = { + hook: { + id: 'claude/pre-tool-use/bash.yaml', + tool: 'claude', + event: 'PreToolUse', + matcher: 'Bash', + handlers: [ + { + type: 'command', + command: './bin/check', + }, + ], + }, + previews: [ + { + target: 'claude', + files: [ + { + path: '/tmp/home/.claude/settings.json', + content: '{"hooks":{}}', + format: 'json', + }, + ], + warnings: [], + }, + ], + }; + + const managedRulesList = { + rules: [ + { + id: 'claude/backend.md', + tool: 'claude', + name: 'backend.md', + relativePath: 'claude/backend.md', + content: '# Backend', + }, + ], + }; + + const discoveredRulesList = { + warnings: [], + rules: [ + { + id: 'claude:project:backend', + name: 'backend-rule', + sourceTool: 'claude', + scope: 'project', + path: '/tmp/project/.claude/rules/backend.md', + exists: true, + collectible: true, + collectReason: 'Ready to import', + content: '# Backend Rule\n\nFollow the backend checklist.', + size: 41, + isScoped: false, + stats: { + wordCount: 6, + lineCount: 3, + tokenCount: 12, + }, + }, + ], + }; + + const skill = { + name: 'cli-e2e-test', + flatName: 'cli-e2e-test', + relPath: 'cli-e2e-test', + sourcePath: '/tmp/skills/cli-e2e-test', + isInRepo: false, + targets: ['claude', 'codex'], + }; + + const auditSummary = { + total: 0, + passed: 0, + warning: 0, + failed: 0, + critical: 0, + high: 1, + medium: 0, + low: 0, + info: 0, + threshold: 'warn', + riskScore: 24, + riskLabel: 'high', + }; + + const managedRuleDetail = { + rule: { + id: 'claude/backend.md', + tool: 'claude', + name: 'backend.md', + relativePath: 'claude/backend.md', + content: '# Backend', + }, + previews: [ + { + target: 'claude', + files: [ + { + path: '/tmp/home/.claude/rules/backend.md', + content: '# Backend', + format: 'markdown', + }, + ], + warnings: [], + }, + ], + }; + + class MockEventSource { + url: string; + listeners = new Map void>>(); + closed = false; + + constructor(url: string) { + this.url = url; + setTimeout(() => { + this.dispatch('start', { total: 0 }); + this.dispatch('done', { diffs: [] }); + }, 0); + } + + addEventListener(type: string, listener: (event: MessageEvent) => void) { + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + } + this.listeners.get(type)?.add(listener); + } + + removeEventListener(type: string, listener: (event: MessageEvent) => void) { + this.listeners.get(type)?.delete(listener); + } + + close() { + this.closed = true; + } + + dispatch(type: string, data: unknown) { + if (this.closed) return; + const event = new MessageEvent(type, { data: JSON.stringify(data) }); + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } + } + + // @ts-expect-error test-only browser shim + window.EventSource = MockEventSource; + + window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === 'string' || input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl, window.location.origin); + const method = (init?.method ?? (typeof input === 'string' || input instanceof URL ? 'GET' : input.method ?? 'GET')).toUpperCase(); + + if (!url.pathname.startsWith('/api/')) { + return originalFetch(input, init); + } + + if (method === 'GET' && url.pathname === '/api/overview') { + return json(overview); + } + + if (method === 'GET' && url.pathname === '/api/managed/hooks') { + return json({ + hooks: [ + { + id: 'claude/pre-tool-use/bash.yaml', + tool: 'claude', + event: 'PreToolUse', + matcher: 'Bash', + handlers: [ + { + type: 'command', + command: './bin/check', + }, + ], + }, + ], + }); + } + + if (method === 'GET' && url.pathname === '/api/managed/rules') { + return json(managedRulesList); + } + + if (method === 'GET' && url.pathname === '/api/rules') { + return json(discoveredRulesList); + } + + if (method === 'POST' && url.pathname === '/api/managed/rules') { + return json(managedRuleDetail); + } + + if (method === 'POST' && url.pathname === '/api/managed/rules/collect') { + return json({ + created: ['claude/backend.md'], + overwritten: [], + skipped: [], + }); + } + + if (method === 'GET' && url.pathname === '/api/hooks') { + return json({ + warnings: [], + hooks: [ + { + groupId: 'claude:project:PreToolUse:Bash', + sourceTool: 'claude', + scope: 'project', + event: 'PreToolUse', + matcher: 'Bash', + actionType: 'command', + command: './bin/check', + path: '/tmp/project/.claude/settings.json', + collectible: true, + collectReason: 'Can be collected into managed hooks', + }, + ], + }); + } + + if (method === 'GET' && url.pathname.startsWith('/api/managed/hooks/')) { + return json(managedHookDetail); + } + + if (method === 'GET' && url.pathname.startsWith('/api/managed/rules/')) { + return json(managedRuleDetail); + } + + if (method === 'GET' && url.pathname === '/api/skills') { + return json({ skills: [skill] }); + } + + if (method === 'GET' && url.pathname === '/api/skills/cli-e2e-test') { + return json({ + skill, + skillMdContent: '---\nname: skillshare-cli-e2e-test\ndescription: Run isolated E2E tests in devcontainer.\n---\n\n# Flow\n\nRun isolated E2E tests in devcontainer.', + files: ['SKILL.md'], + stats: { + wordCount: 14, + lineCount: 6, + tokenCount: 32, + }, + }); + } + + if (method === 'GET' && url.pathname === '/api/audit/cli-e2e-test') { + return json({ + result: { + skillName: 'cli-e2e-test', + findings: [], + riskScore: 24, + riskLabel: 'high', + threshold: 'warn', + isBlocked: false, + }, + summary: auditSummary, + }); + } + + if (method === 'GET' && url.pathname === '/api/diff') { + return json({ diffs: [] }); + } + + if (method === 'POST' && url.pathname === '/api/managed/hooks/collect') { + return json({ + created: ['claude/pre-tool-use/bash.yaml'], + overwritten: [], + skipped: [], + }); + } + + if (method === 'POST' && url.pathname === '/api/sync') { + return json({ + results: [ + { + resource: 'skills', + target: 'claude', + linked: ['a'], + updated: [], + skipped: [], + pruned: [], + }, + { + resource: 'rules', + target: 'claude', + linked: ['claude/backend.md'], + updated: [], + skipped: [], + pruned: [], + }, + { + resource: 'hooks', + target: 'codex', + linked: ['codex/pre-tool-use/bash.yaml'], + updated: [], + skipped: [], + pruned: [], + }, + ], + }); + } + + return new Response(JSON.stringify({ error: `Unhandled mock: ${method} ${url.pathname}` }), { + status: 404, + headers: { + 'Content-Type': 'application/json', + }, + }); + }; + }); +} + +test.beforeEach(async ({ page }) => { + await installBrowserMocks(page); +}); + +test('smokes dashboard, hooks, and sync parity flows', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Rules', exact: true })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Hooks', exact: true })).toBeVisible(); + + await page.goto('/rules/new'); + const ruleForm = page.locator('form').first(); + await ruleForm.getByLabel('Tool').fill('claude'); + await ruleForm.getByLabel('Relative Path').fill('claude/backend.md'); + await ruleForm.getByLabel('Content').fill('# Backend'); + await ruleForm.getByRole('button', { name: /save rule/i }).click(); + await expect(page).toHaveURL(/\/rules\/manage\/claude\/backend\.md$/); + await expect(page.getByRole('button', { name: /delete rule/i })).toBeVisible(); + await expect(page.getByText('/tmp/home/.claude/rules/backend.md')).toBeVisible(); + + await page.goto('/hooks'); + await expect(page.getByRole('heading', { name: 'Hooks' })).toBeVisible(); + await expect(page.getByRole('tab', { name: /hooks/i })).toBeVisible(); + await expect(page.getByText('claude/pre-tool-use/bash.yaml')).toBeVisible(); + + await page.getByRole('tab', { name: /discovered/i }).click(); + await expect(page.getByRole('checkbox', { name: /collect bash/i })).toBeVisible(); + await page.getByRole('link', { name: 'claude project PreToolUse Bash' }).click(); + await expect(page).toHaveURL(/\/hooks\/discovered\//); + await expect(page.getByRole('heading', { name: 'claude project PreToolUse Bash' })).toBeVisible(); + await expect(page.getByText('./bin/check')).toBeVisible(); + await page.getByRole('button', { name: /collect & edit/i }).click(); + await expect(page).toHaveURL(/\/hooks\/manage\/claude\/pre-tool-use\/bash\.yaml$/); + + await page.goto('/rules?mode=discovered'); + await expect(page.getByRole('heading', { name: 'Rules' })).toBeVisible(); + await expect(page.getByRole('tab', { name: /discovered \(1\)/i })).toBeVisible(); + await page.getByRole('button', { name: /view rule/i }).click(); + await expect(page).toHaveURL(/\/rules\/discovered\//); + await expect(page.getByRole('heading', { name: 'backend-rule' })).toBeVisible(); + await expect(page.getByText('41 bytes')).toBeVisible(); + await page.getByRole('button', { name: /collect & edit/i }).click(); + await expect(page).toHaveURL(/\/rules\/manage\/claude\/backend\.md$/); + await expect(page.getByRole('heading', { name: 'backend.md' })).toBeVisible(); + await expect(page.getByText('/tmp/home/.claude/rules/backend.md')).toBeVisible(); + + await page.goto('/skills/cli-e2e-test'); + await expect(page.getByRole('heading', { name: 'cli-e2e-test' })).toBeVisible(); + await expect(page.getByText(/tokens/i)).toBeVisible(); + + await page.goto('/hooks/manage/claude/pre-tool-use/bash.yaml'); + await expect(page.getByRole('heading', { name: 'claude/pre-tool-use/bash.yaml' })).toBeVisible(); + await expect(page.getByText(/compiled preview/i)).toBeVisible(); + await expect(page.getByText('/tmp/home/.claude/settings.json')).toBeVisible(); + + await page.goto('/sync'); + await expect(page.getByRole('heading', { name: 'Sync' })).toBeVisible(); + await page.getByRole('button', { name: /sync now/i }).click(); + await expect(page.getByRole('heading', { name: /^skills$/i })).toBeVisible(); + await expect(page.getByRole('heading', { name: /^rules$/i })).toBeVisible(); + await expect(page.getByRole('heading', { name: /^hooks$/i })).toBeVisible(); +}); diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 00000000..17242e9b --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,6458 @@ +{ + "name": "ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "version": "0.0.0", + "dependencies": { + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/language": "^6.12.2", + "@codemirror/lint": "^6.9.5", + "@codemirror/view": "^6.39.12", + "@lezer/highlight": "^1.2.3", + "@tailwindcss/vite": "^4.2.0", + "@tanstack/react-query": "^5.90.21", + "@uiw/react-codemirror": "^4.25.4", + "lucide-react": "^0.563.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.13.0", + "react-virtuoso": "^4.18.1", + "remark-gfm": "^4.0.1", + "tailwindcss": "^4.2.0", + "yaml": "^2.8.2" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@playwright/test": "^1.55.0", + "@tanstack/react-query-devtools": "^5.91.3", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.0", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^28.1.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^8.0.0", + "vitest": "^4.1.0" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.9.tgz", + "integrity": "sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz", + "integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", + "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.97.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.97.0.tgz", + "integrity": "sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.97.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.97.0.tgz", + "integrity": "sha512-ZMjAuYhQCKwKLKFMrD+HJDehHwWBVTGOuWBf4vEjR9unO+UGUjQ1mw2TuVbQKoLN/eRwB7qtlPsWBqobBoRBMQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.97.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.97.0.tgz", + "integrity": "sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.97.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.97.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.97.0.tgz", + "integrity": "sha512-X4/VZKCbBIRj8cVD/oZCKTwwPmFXrY1VOfwUT5qI/+/JZYAUS+8vGNMqwBXbaAu1ZsVzzDzkT/wtBE/5OtQYGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.97.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.97.0", + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.9", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.9.tgz", + "integrity": "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.9", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.9.tgz", + "integrity": "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.9", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", + "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.335", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", + "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.563.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz", + "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-router": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz", + "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-virtuoso": { + "version": "4.18.4", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.4.tgz", + "integrity": "sha512-DNM4Wy2tMA/J6ejMaDdqecOug31rOwgSRg4C/Dw6Iox4dJe9qwcx32M8HdhkE5uHEVVZh7h0koYwAsCSNdxGfQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", + "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.1", + "@typescript-eslint/parser": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/ui/package.json b/ui/package.json index 6ccad76f..fa5bba13 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,7 +8,8 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "test:e2e": "playwright test" }, "dependencies": { "@codemirror/lang-javascript": "^6.2.4", @@ -34,6 +35,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.55.0", "@tanstack/react-query-devtools": "^5.91.3", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 00000000..bdaf3e8e --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'line', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'on-first-retry', + }, + webServer: { + command: 'npm run dev -- --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 9fbaa738..9e1f5a26 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -27,6 +27,12 @@ const UpdatePage = lazy(() => import('./pages/UpdatePage')); const TrashPage = lazy(() => import('./pages/TrashPage')); const AuditPage = lazy(() => import('./pages/AuditPage')); const AuditRulesPage = lazy(() => import('./pages/AuditRulesPage')); +const RulesPage = lazy(() => import('./pages/RulesPage')); +const DiscoveredRuleDetailPage = lazy(() => import('./pages/DiscoveredRuleDetailPage')); +const RuleDetailPage = lazy(() => import('./pages/RuleDetailPage')); +const HooksPage = lazy(() => import('./pages/HooksPage')); +const DiscoveredHookDetailPage = lazy(() => import('./pages/DiscoveredHookDetailPage')); +const HookDetailPage = lazy(() => import('./pages/HookDetailPage')); const LogPage = lazy(() => import('./pages/LogPage')); const ConfigPage = lazy(() => import('./pages/ConfigPage')); const FilterStudioPage = lazy(() => import('./pages/FilterStudioPage')); @@ -43,44 +49,52 @@ export default function App() { return ( - - - - - - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - - - + + + + + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + + diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index ebbde988..ecaefda4 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -10,6 +10,18 @@ export class ApiError extends Error { } } +function errorMessageFromPayload(payload: unknown, fallback: string): string { + if ( + typeof payload === 'object' && + payload !== null && + 'error' in payload && + typeof payload.error === 'string' + ) { + return payload.error; + } + return fallback; +} + export async function apiFetch(path: string, init?: RequestInit): Promise { let res: Response; try { @@ -24,14 +36,14 @@ export async function apiFetch(path: string, init?: RequestInit): Promise if (!text) { throw new ApiError(res.status || 502, 'Empty response from server (request may have timed out)'); } - let data: any; + let data: unknown; try { data = JSON.parse(text); } catch { throw new ApiError(res.status || 502, `Invalid JSON response: ${text.slice(0, 200)}`); } if (!res.ok) { - throw new ApiError(res.status, data.error ?? res.statusText); + throw new ApiError(res.status, errorMessageFromPayload(data, res.statusText)); } return data as T; } @@ -39,24 +51,28 @@ export async function apiFetch(path: string, init?: RequestInit): Promise // createSSEStream creates an EventSource with the standard done/error lifecycle. // The `handlers` map registers named SSE event listeners; the special key "done" // is treated as the terminal event that closes the connection. -function createSSEStream( +type SSEHandlers> = { + [K in keyof TEvents]: (data: TEvents[K]) => void; +}; + +function createSSEStream>( url: string, - handlers: Record void>, + handlers: SSEHandlers, onError: (err: Error) => void, errorMessage: string, ): EventSource { const es = new EventSource(url); let completed = false; - for (const [event, handler] of Object.entries(handlers)) { + for (const [event, handler] of Object.entries(handlers) as Array<[keyof TEvents, SSEHandlers[keyof TEvents]]>) { if (event === 'done') { es.addEventListener('done', (e) => { completed = true; es.close(); - handler(JSON.parse((e as MessageEvent).data)); + handler(JSON.parse((e as MessageEvent).data) as TEvents[typeof event]); }); } else { - es.addEventListener(event, (e) => { - handler(JSON.parse((e as MessageEvent).data)); + es.addEventListener(String(event), (e) => { + handler(JSON.parse((e as MessageEvent).data) as TEvents[typeof event]); }); } } @@ -129,7 +145,7 @@ export const api = { listSkills: (kind?: 'skill' | 'agent') => apiFetch<{ resources: Skill[] }>(kind ? `/resources?kind=${kind}` : '/resources'), getResource: (name: string, kind?: 'skill' | 'agent') => - apiFetch<{ resource: Skill; skillMdContent: string; files: string[] }>( + apiFetch<{ resource: Skill; skillMdContent: string; files: string[]; stats: SkillStats }>( `/resources/${encodeURIComponent(name)}${kind ? `?kind=${kind}` : ''}` ), getSkill: (name: string, kind?: 'skill' | 'agent') => @@ -231,7 +247,12 @@ export const api = { onDone: (data: { diffs: DiffTarget[] } & IgnoreSources) => void, onError: (err: Error) => void, ): EventSource => - createSSEStream(BASE + '/diff/stream', { + createSSEStream<{ + discovering: unknown; + start: { total: number }; + result: { diff: DiffTarget; checked: number }; + done: { diffs: DiffTarget[] } & IgnoreSources; + }>(BASE + '/diff/stream', { discovering: () => onDiscovering(), start: (d) => onStart(d.total), result: (d) => onResult(d.diff, d.checked), @@ -269,7 +290,12 @@ export const api = { onDone: (data: CheckResult) => void, onError: (err: Error) => void, ): EventSource => - createSSEStream(BASE + '/check/stream', { + createSSEStream<{ + discovering: unknown; + start: { total: number }; + progress: { checked: number }; + done: CheckResult; + }>(BASE + '/check/stream', { discovering: () => onDiscovering(), start: (d) => onStart(d.total), progress: (d) => onProgress(d.checked), @@ -308,7 +334,11 @@ export const api = { if (opts?.names?.length) params.set('names', opts.names.join(',')); if (opts?.force) params.set('force', 'true'); if (opts?.skipAudit) params.set('skipAudit', 'true'); - return createSSEStream(`${BASE}/update/stream?${params.toString()}`, { + return createSSEStream<{ + start: { total: number }; + result: UpdateResultItem; + done: { results: UpdateResultItem[]; summary: UpdateStreamSummary }; + }>(`${BASE}/update/stream?${params.toString()}`, { start: (d) => onStart(d.total), result: onResult, done: onDone, @@ -446,7 +476,11 @@ export const api = { onError: (err: Error) => void, kind?: 'skills' | 'agents', ): EventSource => - createSSEStream(BASE + `/audit/stream${kind ? '?kind=' + kind : ''}`, { + createSSEStream<{ + start: { total: number }; + progress: { scanned: number }; + done: AuditAllResponse; + }>(BASE + `/audit/stream${kind ? '?kind=' + kind : ''}`, { start: (d) => onStart(d.total), progress: (d) => onProgress(d.scanned), done: onDone, @@ -474,6 +508,80 @@ export const api = { method: 'POST', }), + // Rules diagnostics + listRules: () => apiFetch('/rules'), + listHooks: () => apiFetch('/hooks'), + + // Managed rules + managedRules: { + list: () => apiFetch<{ rules: ManagedRule[] }>('/managed/rules'), + get: (id: string) => apiFetch(`/managed/rules/${encodeURIComponent(id)}`), + create: (body: ManagedRuleSaveRequest) => + apiFetch('/managed/rules', { + method: 'POST', + body: JSON.stringify(body), + }), + update: (id: string, body: ManagedRuleSaveRequest) => + apiFetch(`/managed/rules/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify({ id, ...body }), + }), + setTargets: (id: string, target: string | null) => + apiFetch(`/managed/rules/${encodeURIComponent(id)}/targets`, { + method: 'PATCH', + body: JSON.stringify({ target: target ?? '' }), + }), + setDisabled: (id: string, disabled: boolean) => + apiFetch(`/managed/rules/${encodeURIComponent(id)}/disabled`, { + method: 'PATCH', + body: JSON.stringify({ disabled }), + }), + remove: (id: string) => + apiFetch<{ success: boolean }>(`/managed/rules/${encodeURIComponent(id)}`, { + method: 'DELETE', + }), + collect: (body: ManagedRuleCollectRequest) => + apiFetch('/managed/rules/collect', { + method: 'POST', + body: JSON.stringify(body), + }), + }, + + // Managed hooks + managedHooks: { + list: () => apiFetch<{ hooks: ManagedHook[] }>('/managed/hooks'), + get: (id: string) => apiFetch(`/managed/hooks/${encodeURIComponent(id)}`), + create: (body: ManagedHookSaveRequest) => + apiFetch('/managed/hooks', { + method: 'POST', + body: JSON.stringify(body), + }), + update: (id: string, body: ManagedHookSaveRequest) => + apiFetch(`/managed/hooks/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify({ id, ...body }), + }), + setTargets: (id: string, target: string | null) => + apiFetch(`/managed/hooks/${encodeURIComponent(id)}/targets`, { + method: 'PATCH', + body: JSON.stringify({ target: target ?? '' }), + }), + setDisabled: (id: string, disabled: boolean) => + apiFetch(`/managed/hooks/${encodeURIComponent(id)}/disabled`, { + method: 'PATCH', + body: JSON.stringify({ disabled }), + }), + remove: (id: string) => + apiFetch<{ success: boolean }>(`/managed/hooks/${encodeURIComponent(id)}`, { + method: 'DELETE', + }), + collect: (body: ManagedHookCollectRequest) => + apiFetch('/managed/hooks/collect', { + method: 'POST', + body: JSON.stringify(body), + }), + }, + // Git gitStatus: () => apiFetch('/git/status'), gitBranches: (opts?: { fetch?: boolean }) => @@ -534,6 +642,8 @@ export interface Overview { targetCount: number; mode: string; version: string; + managedRulesCount?: number; + managedHooksCount?: number; trackedRepos: TrackedRepo[]; isProjectMode: boolean; projectRoot?: string; @@ -583,7 +693,8 @@ export interface TemplatesResponse { export interface CreateSkillRequest { name: string; - pattern: string; + kind?: 'skill' | 'agent'; + pattern?: string; category?: string; scaffoldDirs?: string[]; } @@ -591,6 +702,7 @@ export interface CreateSkillRequest { export interface CreateSkillResponse { skill: { name: string; + kind: 'skill' | 'agent'; flatName: string; relPath: string; sourcePath: string; @@ -598,6 +710,12 @@ export interface CreateSkillResponse { createdFiles: string[]; } +export interface SkillStats { + wordCount: number; + lineCount: number; + tokenCount: number; +} + export interface Target { name: string; path: string; @@ -620,6 +738,7 @@ export interface Target { } export interface SyncResult { + resource: string; target: string; linked: string[]; updated: string[]; @@ -896,7 +1015,7 @@ export interface PullResponse { export interface LogEntry { ts: string; cmd: string; - args?: Record; + args?: Record; status: string; msg?: string; ms?: number; @@ -984,6 +1103,144 @@ export interface AuditRulesResponse { path: string; } +export interface RuleItem { + id?: string; + name: string; + sourceTool: string; + scope: 'user' | 'project'; + path: string; + exists: boolean; + content: string; + size: number; + scopedPaths?: string[]; + isScoped: boolean; + stats?: SkillStats; + collectible?: boolean; + collectReason?: string; +} + +export interface RulesListResponse { + rules: RuleItem[]; + warnings: string[]; +} + +export interface ManagedRule { + id: string; + tool: string; + name: string; + relativePath: string; + content: string; + targets?: string[]; + sourceType?: 'local' | 'github' | 'tracked' | string; + disabled?: boolean; +} + +export interface ManagedRuleSaveRequest { + tool: string; + relativePath: string; + content: string; + targets?: string[]; + sourceType?: 'local' | 'github' | 'tracked' | string; + disabled?: boolean; +} + +export interface ManagedRulePreviewFile { + path: string; + content: string; + format: string; +} + +export interface ManagedPreview { + target: string; + files: ManagedRulePreviewFile[]; + warnings?: string[]; +} + +export type ManagedRulePreview = ManagedPreview; + +export interface ManagedRuleDetailResponse { + rule: ManagedRule; + previews: ManagedRulePreview[]; +} + +export interface ManagedRuleCollectRequest { + ids: string[]; + strategy: 'overwrite' | 'skip'; +} + +export interface ManagedCollectResult { + created: string[]; + overwritten: string[]; + skipped: string[]; +} + +export interface ManagedHook { + id: string; + tool: string; + event: string; + matcher?: string; + handlers: ManagedHookHandler[]; + targets?: string[]; + sourceType?: 'local' | 'github' | 'tracked' | string; + disabled?: boolean; +} + +export interface ManagedHookHandler { + type: 'command' | 'http' | 'prompt' | 'agent'; + command?: string; + url?: string; + prompt?: string; + timeout?: string; + timeoutSec?: number; + statusMessage?: string; +} + +export interface ManagedHookSaveRequest { + id?: string; + tool: string; + event: string; + matcher?: string; + handlers: ManagedHookHandler[]; + targets?: string[]; + sourceType?: 'local' | 'github' | 'tracked' | string; + disabled?: boolean; +} + +export type ManagedHookPreview = ManagedPreview; + +export interface ManagedHookDetailResponse { + hook: ManagedHook; + previews: ManagedHookPreview[]; +} + +export interface ManagedHookCollectRequest { + groupIds: string[]; + strategy: 'overwrite' | 'skip'; +} + +export interface HookItem { + groupId?: string; + sourceTool: string; + scope: 'user' | 'project'; + event: string; + matcher?: string; + actionType: 'command' | 'http' | 'prompt' | 'agent'; + path: string; + command?: string; + url?: string; + prompt?: string; + timeout?: string; + timeoutSec?: number; + statusMessage?: string; + collectible?: boolean; + collectReason?: string; +} + +export interface HooksListResponse { + hooks: HookItem[]; + warnings: string[]; +} + export interface CompiledRule { id: string; severity: string; diff --git a/ui/src/components/Card.tsx b/ui/src/components/Card.tsx index 14ecb2a5..14dbaba4 100644 --- a/ui/src/components/Card.tsx +++ b/ui/src/components/Card.tsx @@ -4,25 +4,28 @@ import { shadows } from '../design'; interface CardProps { children: ReactNode; className?: string; - variant?: 'default' | 'accent' | 'outlined'; + variant?: 'default' | 'accent' | 'outlined' | 'postit'; hover?: boolean; overflow?: boolean; tilt?: boolean; padding?: 'none' | 'sm' | 'md'; style?: CSSProperties; skillCard?: boolean; + decoration?: 'tape' | 'none'; } const variantStyles = { default: 'bg-surface border border-muted', accent: 'bg-surface border-2 border-muted-dark/30', outlined: 'border border-muted', + postit: 'bg-surface border border-muted', }; const variantShadows = { default: shadows.sm, accent: shadows.sm, outlined: 'none', + postit: shadows.sm, }; const paddingClasses = { @@ -41,12 +44,15 @@ export default function Card({ padding = 'md', style, skillCard = false, + decoration = 'none', }: CardProps) { + const showTape = skillCard || decoration === 'tape'; + return (
{ + it('renders clean previews even when the API omits warnings', () => { + const preview = { + target: 'claude', + files: [ + { + path: '/tmp/home/.claude/rules/e2e-ui-test.md', + content: '# E2E UI Test', + format: 'markdown', + }, + ], + } as ManagedPreview; + + render(); + + expect(screen.getByText('Preview for claude')).toBeInTheDocument(); + expect(screen.getByText('1 file')).toBeInTheDocument(); + expect(screen.getByText('/tmp/home/.claude/rules/e2e-ui-test.md')).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/CompiledPreviewCard.tsx b/ui/src/components/CompiledPreviewCard.tsx new file mode 100644 index 00000000..cc133790 --- /dev/null +++ b/ui/src/components/CompiledPreviewCard.tsx @@ -0,0 +1,48 @@ +import Card from './Card'; +import Badge from './Badge'; +import type { ManagedPreview } from '../api/client'; + +interface CompiledPreviewCardProps { + preview: ManagedPreview; +} + +export default function CompiledPreviewCard({ preview }: CompiledPreviewCardProps) { + const warnings = preview.warnings ?? []; + + return ( + +
+

+ Preview for {preview.target} +

+ {preview.files.length} file{preview.files.length !== 1 ? 's' : ''} +
+ + {warnings.length > 0 && ( +
+ {warnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ )} + +
+ {preview.files.map((file) => ( +
+
+ {file.format} +

+ {file.path} +

+
+
+              {file.content}
+            
+
+ ))} +
+
+ ); +} diff --git a/ui/src/components/CopyButton.tsx b/ui/src/components/CopyButton.tsx index 1d7c3a1a..5f1d2c47 100644 --- a/ui/src/components/CopyButton.tsx +++ b/ui/src/components/CopyButton.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type CSSProperties } from 'react'; import { Check, Copy } from 'lucide-react'; import { useToast } from './Toast'; @@ -6,6 +6,7 @@ interface CopyButtonProps { value: string; title?: string; className?: string; + style?: CSSProperties; copiedLabel?: string; copiedLabelClassName?: string; errorMessage?: string; @@ -19,6 +20,7 @@ export default function CopyButton({ value, title = 'Copy to clipboard', className, + style, copiedLabel = 'Copied!', copiedLabelClassName = 'text-xs', errorMessage = 'Failed to copy to clipboard.', @@ -63,6 +65,7 @@ export default function CopyButton({ type="button" onClick={handleCopy} className={className ? `${baseClassName} ${className}` : baseClassName} + style={style} title={title} aria-label={title} > diff --git a/ui/src/components/FileViewerModal.tsx b/ui/src/components/FileViewerModal.tsx index ada5f84a..581331c1 100644 --- a/ui/src/components/FileViewerModal.tsx +++ b/ui/src/components/FileViewerModal.tsx @@ -54,7 +54,7 @@ export default function FileViewerModal({ skillName, filepath, sourcePath, onClo }, [data, filepath]); return ( - + {/* Header */}

void; +} + +export default function FilterChip({ + label, + icon, + active, + count, + onClick, +}: FilterChipProps) { + return ( + + ); +} diff --git a/ui/src/components/HandButton.tsx b/ui/src/components/HandButton.tsx new file mode 100644 index 00000000..389edbb2 --- /dev/null +++ b/ui/src/components/HandButton.tsx @@ -0,0 +1,82 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import { shadows, wobbly } from '../design'; + +interface HandButtonProps extends ButtonHTMLAttributes { + children: ReactNode; + variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; +} + +const variantClasses = { + primary: + 'bg-surface border-[3px] border-pencil text-pencil hover:bg-accent hover:text-white hover:border-accent', + secondary: + 'bg-muted border-2 border-pencil-light text-pencil hover:bg-blue hover:text-white hover:border-blue', + danger: + 'bg-surface border-2 border-danger text-danger hover:bg-danger hover:text-white', + ghost: + 'bg-transparent border-2 border-dashed border-pencil-light text-pencil-light hover:border-pencil hover:text-pencil', +} as const; + +const sizeClasses = { + sm: 'px-3 py-1.5 text-base', + md: 'px-5 py-2.5 text-base', + lg: 'px-8 py-3.5 text-lg', +} as const; + +export default function HandButton({ + children, + variant = 'primary', + size = 'md', + className = '', + disabled, + style, + ...props +}: HandButtonProps) { + return ( + + ); +} diff --git a/ui/src/components/HandInput.tsx b/ui/src/components/HandInput.tsx new file mode 100644 index 00000000..0837aadd --- /dev/null +++ b/ui/src/components/HandInput.tsx @@ -0,0 +1,288 @@ +import { useState, useRef, useEffect, useCallback, useId } from 'react'; +import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react'; +import { Check, ChevronDown } from 'lucide-react'; +import { wobbly, shadows } from '../design'; + +interface HandInputProps extends InputHTMLAttributes { + label?: string; +} + +export function HandInput({ label, className = '', style, id, ...props }: HandInputProps) { + const autoId = useId(); + const inputId = id ?? autoId; + + return ( +
+ {label && ( + + )} + +
+ ); +} + +interface HandTextareaProps extends TextareaHTMLAttributes { + label?: string; +} + +export function HandTextarea({ label, className = '', style, id, ...props }: HandTextareaProps) { + const autoId = useId(); + const inputId = id ?? autoId; + + return ( +
+ {label && ( + + )} +