From f3da4b85058a9cfe5d3f9e283fe2c174c7f5d49b Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:44:20 -0700 Subject: [PATCH 01/55] feat: add managed rules and hooks parity --- cmd/skillshare/backup.go | 48 +- cmd/skillshare/backup_restore_test.go | 33 + cmd/skillshare/collect.go | 284 +- cmd/skillshare/collect_project.go | 27 +- cmd/skillshare/init_test.go | 2 + cmd/skillshare/main.go | 8 +- cmd/skillshare/managed_resources.go | 1066 +++ cmd/skillshare/resource_flags.go | 115 + cmd/skillshare/resource_flags_test.go | 92 + cmd/skillshare/restore_tui.go | 130 +- cmd/skillshare/restore_tui_test.go | 63 + cmd/skillshare/sync.go | 287 +- cmd/skillshare/sync_parallel.go | 5 +- cmd/skillshare/sync_project.go | 113 +- cmd/skillshare/sync_resources_test.go | 780 ++ cmd/skillshare/sync_validation.go | 85 + go.mod | 3 + go.sum | 10 +- internal/backup/backup.go | 18 +- internal/backup/backup_test.go | 590 +- internal/backup/manifest.go | 327 + internal/backup/restore.go | 589 +- internal/backup/timestamp.go | 62 + internal/config/resources.go | 19 + internal/config/targets.go | 112 +- internal/config/targets_resolve_test.go | 59 + internal/config/targets_test.go | 23 + internal/inspect/fifo_test_other.go | 9 + internal/inspect/fifo_test_unix.go | 9 + internal/inspect/file_open_other.go | 9 + internal/inspect/file_open_unix.go | 17 + internal/inspect/hooks.go | 513 ++ internal/inspect/hooks_test.go | 974 +++ internal/inspect/rules.go | 405 ++ internal/inspect/rules_test.go | 797 ++ internal/inspect/types.go | 43 + internal/resources/adapters/claude_hooks.go | 63 + internal/resources/adapters/claude_rules.go | 114 + internal/resources/adapters/codex_hooks.go | 514 ++ internal/resources/adapters/codex_rules.go | 45 + internal/resources/adapters/gemini_rules.go | 59 + internal/resources/adapters/hooks_common.go | 248 + internal/resources/adapters/hooks_test.go | 394 + internal/resources/adapters/rules_test.go | 129 + internal/resources/adapters/types.go | 38 + internal/resources/apply/files.go | 96 + internal/resources/apply/files_test.go | 78 + .../resources/apply/replace_nonwindows.go | 9 + internal/resources/apply/replace_windows.go | 9 + internal/resources/hooks/collect.go | 381 + internal/resources/hooks/collect_test.go | 462 ++ internal/resources/hooks/compile.go | 136 + internal/resources/hooks/compile_test.go | 68 + internal/resources/hooks/identity.go | 6 + .../resources/hooks/replace_nonwindows.go | 9 + internal/resources/hooks/replace_windows.go | 9 + internal/resources/hooks/store.go | 372 + internal/resources/hooks/store_test.go | 345 + internal/resources/hooks/types.go | 32 + internal/resources/rules/collect.go | 270 + internal/resources/rules/collect_test.go | 272 + internal/resources/rules/compile.go | 120 + internal/resources/rules/compile_test.go | 103 + internal/resources/rules/errors.go | 9 + .../resources/rules/replace_nonwindows.go | 9 + internal/resources/rules/replace_windows.go | 9 + internal/resources/rules/store.go | 252 + internal/resources/rules/store_test.go | 302 + internal/resources/rules/types.go | 17 + internal/server/content_stats.go | 60 + internal/server/content_stats_test.go | 32 + internal/server/handler_create_skill.go | 90 + internal/server/handler_create_skill_test.go | 58 + internal/server/handler_helpers_test.go | 47 + internal/server/handler_hooks.go | 25 + internal/server/handler_hooks_test.go | 96 + internal/server/handler_managed_hooks.go | 653 ++ internal/server/handler_managed_hooks_test.go | 594 ++ internal/server/handler_managed_rules.go | 530 ++ internal/server/handler_managed_rules_test.go | 891 +++ internal/server/handler_overview.go | 43 +- internal/server/handler_overview_test.go | 53 + internal/server/handler_rules.go | 38 + internal/server/handler_rules_test.go | 109 + internal/server/handler_skills.go | 3 + internal/server/handler_skills_test.go | 77 + internal/server/handler_sync.go | 308 +- internal/server/handler_sync_test.go | 212 +- internal/server/logging_test.go | 31 + internal/server/managed_resource_sync.go | 249 + internal/server/server.go | 30 + ui/e2e/managed-rules-hooks.spec.ts | 404 ++ ui/package-lock.json | 6458 +++++++++++++++++ ui/package.json | 4 +- ui/playwright.config.ts | 26 + ui/src/App.tsx | 90 +- ui/src/api/client.ts | 255 +- ui/src/components/Card.tsx | 10 +- .../components/CompiledPreviewCard.test.tsx | 25 + ui/src/components/CompiledPreviewCard.tsx | 48 + ui/src/components/CopyButton.tsx | 5 +- ui/src/components/FileViewerModal.tsx | 2 +- ui/src/components/FilterChip.tsx | 49 + ui/src/components/HandButton.tsx | 82 + ui/src/components/HandInput.tsx | 288 + ui/src/components/HookEditor.tsx | 233 + ui/src/components/Layout.tsx | 3 + ui/src/components/ManagedModeTabs.tsx | 53 + ui/src/components/RuleEditor.tsx | 74 + ui/src/components/RuleViewerModal.tsx | 104 + ui/src/components/SelectionToggle.tsx | 38 + ui/src/components/hookEditorState.ts | 41 + .../context/__tests__/ThemeContext.test.tsx | 19 + ui/src/design.ts | 2 + ui/src/hooks/useCursorField.ts | 3 - ui/src/lib/hookDiscovery.ts | 67 + ui/src/lib/queryKeys.ts | 16 + ui/src/pages/DashboardPage.tsx | 20 + .../pages/DiscoveredHookDetailPage.test.tsx | 114 + ui/src/pages/DiscoveredHookDetailPage.tsx | 233 + .../pages/DiscoveredRuleDetailPage.test.tsx | 113 + ui/src/pages/DiscoveredRuleDetailPage.tsx | 214 + ui/src/pages/HookDetailPage.test.tsx | 410 ++ ui/src/pages/HookDetailPage.tsx | 237 + ui/src/pages/HooksPage.test.tsx | 230 + ui/src/pages/HooksPage.tsx | 589 ++ ui/src/pages/NewSkillPage.test.tsx | 91 + ui/src/pages/NewSkillPage.tsx | 122 +- ui/src/pages/ResourceDetailPage.tsx | 35 +- ui/src/pages/ResourcesPage.test.tsx | 119 + ui/src/pages/ResourcesPage.tsx | 6 +- ui/src/pages/RuleDetailPage.test.tsx | 213 + ui/src/pages/RuleDetailPage.tsx | 204 + ui/src/pages/RulesPage.test.tsx | 174 + ui/src/pages/RulesPage.tsx | 540 ++ ui/src/pages/SkillDetailPage.test.tsx | 122 + ui/src/pages/SyncPage.test.tsx | 104 + ui/src/pages/SyncPage.tsx | 65 +- ui/vite.config.ts | 2 + 139 files changed, 28663 insertions(+), 627 deletions(-) create mode 100644 cmd/skillshare/backup_restore_test.go create mode 100644 cmd/skillshare/managed_resources.go create mode 100644 cmd/skillshare/resource_flags.go create mode 100644 cmd/skillshare/resource_flags_test.go create mode 100644 cmd/skillshare/restore_tui_test.go create mode 100644 cmd/skillshare/sync_resources_test.go create mode 100644 cmd/skillshare/sync_validation.go create mode 100644 internal/backup/manifest.go create mode 100644 internal/backup/timestamp.go create mode 100644 internal/config/resources.go create mode 100644 internal/config/targets_resolve_test.go create mode 100644 internal/inspect/fifo_test_other.go create mode 100644 internal/inspect/fifo_test_unix.go create mode 100644 internal/inspect/file_open_other.go create mode 100644 internal/inspect/file_open_unix.go create mode 100644 internal/inspect/hooks.go create mode 100644 internal/inspect/hooks_test.go create mode 100644 internal/inspect/rules.go create mode 100644 internal/inspect/rules_test.go create mode 100644 internal/inspect/types.go create mode 100644 internal/resources/adapters/claude_hooks.go create mode 100644 internal/resources/adapters/claude_rules.go create mode 100644 internal/resources/adapters/codex_hooks.go create mode 100644 internal/resources/adapters/codex_rules.go create mode 100644 internal/resources/adapters/gemini_rules.go create mode 100644 internal/resources/adapters/hooks_common.go create mode 100644 internal/resources/adapters/hooks_test.go create mode 100644 internal/resources/adapters/rules_test.go create mode 100644 internal/resources/adapters/types.go create mode 100644 internal/resources/apply/files.go create mode 100644 internal/resources/apply/files_test.go create mode 100644 internal/resources/apply/replace_nonwindows.go create mode 100644 internal/resources/apply/replace_windows.go create mode 100644 internal/resources/hooks/collect.go create mode 100644 internal/resources/hooks/collect_test.go create mode 100644 internal/resources/hooks/compile.go create mode 100644 internal/resources/hooks/compile_test.go create mode 100644 internal/resources/hooks/identity.go create mode 100644 internal/resources/hooks/replace_nonwindows.go create mode 100644 internal/resources/hooks/replace_windows.go create mode 100644 internal/resources/hooks/store.go create mode 100644 internal/resources/hooks/store_test.go create mode 100644 internal/resources/hooks/types.go create mode 100644 internal/resources/rules/collect.go create mode 100644 internal/resources/rules/collect_test.go create mode 100644 internal/resources/rules/compile.go create mode 100644 internal/resources/rules/compile_test.go create mode 100644 internal/resources/rules/errors.go create mode 100644 internal/resources/rules/replace_nonwindows.go create mode 100644 internal/resources/rules/replace_windows.go create mode 100644 internal/resources/rules/store.go create mode 100644 internal/resources/rules/store_test.go create mode 100644 internal/resources/rules/types.go create mode 100644 internal/server/content_stats.go create mode 100644 internal/server/content_stats_test.go create mode 100644 internal/server/handler_hooks.go create mode 100644 internal/server/handler_hooks_test.go create mode 100644 internal/server/handler_managed_hooks.go create mode 100644 internal/server/handler_managed_hooks_test.go create mode 100644 internal/server/handler_managed_rules.go create mode 100644 internal/server/handler_managed_rules_test.go create mode 100644 internal/server/handler_rules.go create mode 100644 internal/server/handler_rules_test.go create mode 100644 internal/server/managed_resource_sync.go create mode 100644 ui/e2e/managed-rules-hooks.spec.ts create mode 100644 ui/package-lock.json create mode 100644 ui/playwright.config.ts create mode 100644 ui/src/components/CompiledPreviewCard.test.tsx create mode 100644 ui/src/components/CompiledPreviewCard.tsx create mode 100644 ui/src/components/FilterChip.tsx create mode 100644 ui/src/components/HandButton.tsx create mode 100644 ui/src/components/HandInput.tsx create mode 100644 ui/src/components/HookEditor.tsx create mode 100644 ui/src/components/ManagedModeTabs.tsx create mode 100644 ui/src/components/RuleEditor.tsx create mode 100644 ui/src/components/RuleViewerModal.tsx create mode 100644 ui/src/components/SelectionToggle.tsx create mode 100644 ui/src/components/hookEditorState.ts create mode 100644 ui/src/lib/hookDiscovery.ts create mode 100644 ui/src/pages/DiscoveredHookDetailPage.test.tsx create mode 100644 ui/src/pages/DiscoveredHookDetailPage.tsx create mode 100644 ui/src/pages/DiscoveredRuleDetailPage.test.tsx create mode 100644 ui/src/pages/DiscoveredRuleDetailPage.tsx create mode 100644 ui/src/pages/HookDetailPage.test.tsx create mode 100644 ui/src/pages/HookDetailPage.tsx create mode 100644 ui/src/pages/HooksPage.test.tsx create mode 100644 ui/src/pages/HooksPage.tsx create mode 100644 ui/src/pages/NewSkillPage.test.tsx create mode 100644 ui/src/pages/ResourcesPage.test.tsx create mode 100644 ui/src/pages/RuleDetailPage.test.tsx create mode 100644 ui/src/pages/RuleDetailPage.tsx create mode 100644 ui/src/pages/RulesPage.test.tsx create mode 100644 ui/src/pages/RulesPage.tsx create mode 100644 ui/src/pages/SkillDetailPage.test.tsx create mode 100644 ui/src/pages/SyncPage.test.tsx diff --git a/cmd/skillshare/backup.go b/cmd/skillshare/backup.go index 43a80eaa9..03aa278a5 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 000000000..121b75805 --- /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 09b221a84..eedc34014 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 e0f4a08b4..331a577a7 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 59c8bb461..7abe8754d 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 7e276f959..264f63cf6 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 000000000..c2f61c89e --- /dev/null +++ b/cmd/skillshare/managed_resources.go @@ -0,0 +1,1066 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "skillshare/internal/backup" + "skillshare/internal/config" + "skillshare/internal/inspect" + "skillshare/internal/resources/adapters" + "skillshare/internal/resources/apply" + managedhooks "skillshare/internal/resources/hooks" + managedrules "skillshare/internal/resources/rules" + "skillshare/internal/sync" + "skillshare/internal/ui" +) + +type managedSyncResult struct { + resource string + updated []string + skipped []string + pruned []string +} + +func syncManagedResourcesForEntries(entries []syncTargetEntry, results []syncTargetResult, resources resourceSelection, projectRoot string, dryRun bool) ([]syncTargetResult, int) { + if len(results) == 0 { + results = make([]syncTargetResult, len(entries)) + } + + 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 := make([]string, 0, 2) + errorsByResource := make([]string, 0, 2) + if resources.rules { + ruleResult, err := syncManagedRulesForTarget(entry.name, entry.target, projectRoot, dryRun) + if err != nil { + errorsByResource = append(errorsByResource, err.Error()) + } else { + accumulateManagedSyncResult(&result, &lines, ruleResult) + } + } + if resources.hooks { + hookResult, err := syncManagedHooksForTarget(entry.name, entry.target, projectRoot, dryRun) + if err != nil { + errorsByResource = append(errorsByResource, err.Error()) + } else { + accumulateManagedSyncResult(&result, &lines, hookResult) + } + } + + 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 accumulateManagedSyncResult(targetResult *syncTargetResult, lines *[]string, result managedSyncResult) { + targetResult.stats.updated += len(result.updated) + targetResult.stats.pruned += len(result.pruned) + if line := managedSyncLine(result); line != "" { + *lines = append(*lines, line) + } +} + +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 syncManagedRulesForTarget(name string, target config.TargetConfig, projectRoot string, dryRun bool) (managedSyncResult, error) { + result := managedSyncResult{resource: "rules"} + + compileTarget, compileRoot, ok := resolveManagedRuleTarget(name, target, projectRoot) + if !ok { + return result, nil + } + + store := managedrules.NewStore(projectRoot) + records, err := store.List() + if err != nil { + return result, fmt.Errorf("list managed rules: %w", err) + } + + files, _, err := managedrules.CompileTarget(records, compileTarget, compileRoot) + if err != nil { + if errors.Is(err, managedrules.ErrUnsupportedTarget) { + return result, nil + } + return result, fmt.Errorf("compile managed rules: %w", err) + } + + updated, skipped, err := apply.CompiledFiles(files, dryRun) + if err != nil { + return result, fmt.Errorf("apply managed rules: %w", err) + } + pruned, err := pruneManagedRuleOrphans(compileTarget, compileRoot, files, dryRun) + if err != nil { + return result, fmt.Errorf("prune managed rules: %w", err) + } + + result.updated = updated + result.skipped = skipped + result.pruned = pruned + return result, nil +} + +func syncManagedHooksForTarget(name string, target config.TargetConfig, projectRoot string, dryRun bool) (managedSyncResult, error) { + result := managedSyncResult{resource: "hooks"} + + compileTarget, compileRoot, ok := resolveManagedHookTarget(name, target, projectRoot) + if !ok { + return result, nil + } + + store := managedhooks.NewStore(projectRoot) + records, err := store.List() + if err != nil { + return result, fmt.Errorf("list managed hooks: %w", err) + } + + rawConfig, err := loadManagedHookRawConfig(compileTarget, compileRoot) + if err != nil { + return result, fmt.Errorf("load managed hook config: %w", err) + } + files, _, err := managedhooks.CompileTarget(records, compileTarget, compileRoot, rawConfig) + if err != nil { + return result, fmt.Errorf("compile managed hooks: %w", err) + } + + updated, skipped, err := apply.CompiledFiles(files, dryRun) + if err != nil { + return result, fmt.Errorf("apply managed hooks: %w", err) + } + + result.updated = updated + result.skipped = skipped + return result, nil +} + +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 { + return previewManagedRuleCollect(projectRoot, collectible, force) + } + + strategy := managedrules.StrategySkip + if force { + strategy = managedrules.StrategyOverwrite + } + collected, err := managedrules.Collect(projectRoot, collectible, managedrules.CollectOptions{Strategy: strategy}) + if err != nil { + return nil, fmt.Errorf("collect managed rules: %w", 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 { + return previewManagedHookCollect(projectRoot, collectible, force) + } + + strategy := managedhooks.StrategySkip + if force { + strategy = managedhooks.StrategyOverwrite + } + collected, err := managedhooks.Collect(projectRoot, collectible, managedhooks.CollectOptions{Strategy: strategy}) + if err != nil { + return nil, fmt.Errorf("collect managed hooks: %w", err) + } + return &sync.PullResult{ + Pulled: append(append([]string{}, collected.Created...), collected.Overwritten...), + Skipped: append([]string{}, collected.Skipped...), + Failed: make(map[string]error), + }, nil +} + +func previewManagedRuleCollect(projectRoot string, items []inspect.RuleItem, force bool) (*sync.PullResult, error) { + store := managedrules.NewStore(projectRoot) + existing, err := store.List() + if err != nil { + return nil, err + } + + taken := make(map[string]struct{}, len(existing)) + for _, record := range existing { + taken[record.ID] = struct{}{} + } + + result := &sync.PullResult{Failed: make(map[string]error)} + seen := make(map[string]string) + for _, item := range items { + id, err := managedRuleCollectID(item) + if err != nil { + return nil, err + } + if prior, ok := seen[id]; ok && prior != item.Path { + return nil, fmt.Errorf("collect managed rules: cannot collect %s and %s: canonical managed id %q collides", prior, item.Path, id) + } + seen[id] = item.Path + + if _, exists := taken[id]; exists && !force { + result.Skipped = append(result.Skipped, id) + continue + } + result.Pulled = append(result.Pulled, id) + taken[id] = struct{}{} + } + return result, nil +} + +func previewManagedHookCollect(projectRoot string, items []inspect.HookItem, force bool) (*sync.PullResult, error) { + store := managedhooks.NewStore(projectRoot) + existing, err := store.List() + if err != nil { + return nil, err + } + + taken := make(map[string]struct{}, len(existing)) + for _, record := range existing { + taken[record.ID] = struct{}{} + } + + result := &sync.PullResult{Failed: make(map[string]error)} + groups, err := groupCollectibleHooks(items) + if err != nil { + return nil, err + } + seen := make(map[string]string) + for _, group := range groups { + id, err := managedHookCollectID(group.Tool, group.Event, group.Matcher) + if err != nil { + return nil, err + } + if prior, ok := seen[id]; ok && prior != group.GroupID { + return nil, fmt.Errorf("collect managed hooks: cannot collect %s and %s: canonical managed id %q collides", prior, group.GroupID, id) + } + seen[id] = group.GroupID + + if _, exists := taken[id]; exists && !force { + result.Skipped = append(result.Skipped, id) + continue + } + result.Pulled = append(result.Pulled, id) + taken[id] = struct{}{} + } + return result, nil +} + +type collectibleHookGroup struct { + GroupID string + Tool string + Event string + Matcher string +} + +func groupCollectibleHooks(items []inspect.HookItem) ([]collectibleHookGroup, error) { + groupMap := make(map[string]collectibleHookGroup) + order := make([]string, 0, len(items)) + for _, item := range items { + groupID := strings.TrimSpace(item.GroupID) + if groupID == "" { + return nil, fmt.Errorf("collect managed hooks: cannot collect hook with empty group id") + } + group, exists := groupMap[groupID] + if !exists { + groupMap[groupID] = collectibleHookGroup{ + GroupID: groupID, + Tool: strings.TrimSpace(item.SourceTool), + Event: strings.TrimSpace(item.Event), + Matcher: strings.TrimSpace(item.Matcher), + } + order = append(order, groupID) + continue + } + if group.Tool != strings.TrimSpace(item.SourceTool) || group.Event != strings.TrimSpace(item.Event) || group.Matcher != strings.TrimSpace(item.Matcher) { + return nil, fmt.Errorf("collect managed hooks: cannot collect %s: hook items disagree on source tool, event, or matcher", groupID) + } + } + + groups := make([]collectibleHookGroup, 0, len(order)) + for _, groupID := range order { + groups = append(groups, groupMap[groupID]) + } + return groups, nil +} + +func managedRuleCollectID(item inspect.RuleItem) (string, error) { + tool := strings.ToLower(strings.TrimSpace(item.SourceTool)) + if tool == "" { + return "", fmt.Errorf("collect managed rules: 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 + } + } + + if base == "." || base == "/" || strings.TrimSpace(base) == "" { + return "", fmt.Errorf("collect managed rules: cannot collect %s: invalid rule filename", item.Path) + } + return tool + "/" + base, nil +} + +func managedHookCollectID(tool, event, matcher string) (string, error) { + cleanTool := sanitizeHookPathSegment(tool) + cleanEvent := sanitizeHookPathSegment(event) + cleanMatcher := matcherIdentitySegment(matcher) + if cleanTool == "" { + return "", fmt.Errorf("collect managed hooks: cannot collect hook: missing tool") + } + if cleanEvent == "" { + return "", fmt.Errorf("collect managed hooks: cannot collect hook: missing event") + } + if cleanMatcher == "" { + return "", fmt.Errorf("collect managed hooks: cannot collect hook: missing matcher") + } + return path.Join(cleanTool, cleanEvent, cleanMatcher+".yaml"), 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, 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 := resolveManagedHookTarget(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 := loadManagedHookRawConfig(compileTarget, compileRoot) + if err != nil { + return nil, fmt.Errorf("load managed hook config: %w", err) + } + files, _, err := managedhooks.CompileTarget(records, compileTarget, compileRoot, 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 resolveManagedHookTarget(name string, target config.TargetConfig, projectRoot string) (string, string, bool) { + sc := target.SkillsConfig() + compileTarget, ok := resolveManagedHookTool(name, sc.Path) + if !ok { + return "", "", false + } + if strings.TrimSpace(projectRoot) != "" { + return compileTarget, projectRoot, true + } + return compileTarget, managedHookGlobalRoot(sc.Path), true +} + +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 resolveManagedHookTool(name, targetPath string) (string, bool) { + for _, supported := range []string{"claude", "codex"} { + if config.MatchesTargetName(supported, name) { + return supported, true + } + } + + switch managedHookPathFamily(targetPath) { + case "claude", "codex": + return managedHookPathFamily(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 managedHookGlobalRoot(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": + return filepath.Dir(cleaned) + default: + 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 managedHookPathFamily(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" + default: + return "" + } +} + +func loadManagedHookRawConfig(target, root string) (string, error) { + path, ok := managedHookConfigPath(target, root) + if !ok { + return "", nil + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + return "", err + } + return string(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 + default: + return "", false + } +} + +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)) +} + +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 +} + +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/cmd/skillshare/resource_flags.go b/cmd/skillshare/resource_flags.go new file mode 100644 index 000000000..d2da87b99 --- /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 000000000..583ce5ef3 --- /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 6f2638434..c691211c0 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 000000000..ed159f67a --- /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 559d0bf09..6216fe73c 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, 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 c397196ee..24309fe19 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 08100ceb2..b6e03c73f 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, 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 000000000..7dbe4e1c1 --- /dev/null +++ b/cmd/skillshare/sync_resources_test.go @@ -0,0 +1,780 @@ +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_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 000000000..339fd6f32 --- /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/go.mod b/go.mod index 93e8314bc..3fb851a61 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 cb0636c3b..668207597 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 a75cfd4db..e3fe5968e 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 f2467f5db..4819d320e 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 000000000..bdcfeb7ae --- /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 6f56446b1..6d8a81c6d 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 000000000..0174e4d5f --- /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 000000000..9ad6e66b6 --- /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 883fba573..317533ba7 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 000000000..d37e238b0 --- /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 2c2c9414f..3d20c2190 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 000000000..b15f0257c --- /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 000000000..824186cde --- /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 000000000..285b50ec4 --- /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 000000000..e6c3a3eaf --- /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 000000000..7e169826b --- /dev/null +++ b/internal/inspect/hooks.go @@ -0,0 +1,513 @@ +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"` + 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, 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, groupID string, collectible bool, collectReason string, entryIndex, actionIndex int, rawHook json.RawMessage) (HookItem, string, bool) { + var action struct { + Type string `json:"type"` + 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) + 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, + 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, + 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, + 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, + 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, + 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": + return true, "" + case "gemini": + return false, "unsupported managed hook tool: gemini hooks are diagnostics-only" + } + return true, "" +} diff --git a/internal/inspect/hooks_test.go b/internal/inspect/hooks_test.go new file mode 100644 index 000000000..c02b005e5 --- /dev/null +++ b/internal/inspect/hooks_test.go @@ -0,0 +1,974 @@ +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_GeminiHooksAreNotCollectible(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":"command","command":"./gemini.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 gemini hook item, got %d", len(items)) + } + if items[0].Collectible { + t.Fatal("expected gemini hook to be non-collectible") + } + if !strings.Contains(strings.ToLower(items[0].CollectReason), "unsupported") { + t.Fatalf("collectReason = %q, want reason mentioning unsupported managed hooks", items[0].CollectReason) + } +} + +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 000000000..c2e45bf88 --- /dev/null +++ b/internal/inspect/rules.go @@ -0,0 +1,405 @@ +package inspect + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +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 { + 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 != "" { + 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 000000000..323e0725a --- /dev/null +++ b/internal/inspect/rules_test.go @@ -0,0 +1,797 @@ +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_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 000000000..ccdac1446 --- /dev/null +++ b/internal/inspect/types.go @@ -0,0 +1,43 @@ +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"` + 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 000000000..a4bcab425 --- /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 000000000..4bf109e2e --- /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 000000000..9e02eaa60 --- /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 000000000..130c5abeb --- /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_rules.go b/internal/resources/adapters/gemini_rules.go new file mode 100644 index 000000000..2e7aa6140 --- /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 000000000..cc72de95e --- /dev/null +++ b/internal/resources/adapters/hooks_common.go @@ -0,0 +1,248 @@ +package adapters + +import ( + "bytes" + "encoding/json" + "fmt" + "path" + "path/filepath" + "sort" + "strings" +) + +type hookJSONAction struct { + Type string `json:"type"` + 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"` + 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 == "" && tool != "codex" { + 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, + 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 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 000000000..3f5392956 --- /dev/null +++ b/internal/resources/adapters/hooks_test.go @@ -0,0 +1,394 @@ +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 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/rules_test.go b/internal/resources/adapters/rules_test.go new file mode 100644 index 000000000..c3ffe70b9 --- /dev/null +++ b/internal/resources/adapters/rules_test.go @@ -0,0 +1,129 @@ +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 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 000000000..266c0f1b0 --- /dev/null +++ b/internal/resources/adapters/types.go @@ -0,0 +1,38 @@ +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 + Handlers []HookHandler +} + +// HookHandler is one action within a managed hook record. +type HookHandler struct { + Type 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 000000000..edd7b87a8 --- /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 000000000..2449119b3 --- /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 000000000..f56ceaaa3 --- /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 000000000..d5db6a3c1 --- /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 000000000..f8478ba6a --- /dev/null +++ b/internal/resources/hooks/collect.go @@ -0,0 +1,381 @@ +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 + Path string + Collectible bool + CollectReason string + Items []inspect.HookItem +} + +type collectAppliedWrite struct { + id string + hadPrior bool + priorRecord Record +} + +// 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 + } + + takenIDs := make(map[string]bool, len(existing)+len(discovered)) + existingByID := make(map[string]Record, len(existing)) + for _, record := range existing { + takenIDs[record.ID] = true + existingByID[record.ID] = record + } + + groups, err := groupInspectHooks(discovered) + if err != nil { + return CollectResult{}, err + } + if err := rejectCanonicalIDCollisions(groups); err != nil { + return CollectResult{}, err + } + result := CollectResult{} + plannedWrites := 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 CollectResult{}, fmt.Errorf("cannot collect %s: %s", group.GroupID, reason) + } + + id, err := canonicalRelativePath(group.SourceTool, group.Event, group.Matcher) + if err != nil { + return CollectResult{}, err + } + + save := Save{ + ID: id, + Tool: strings.ToLower(strings.TrimSpace(group.SourceTool)), + Event: strings.TrimSpace(group.Event), + Matcher: strings.TrimSpace(group.Matcher), + Handlers: handlersFromInspectHooks(sortedHookItems(group.Items)), + } + + exists := takenIDs[id] + switch { + case !exists: + plannedWrites = append(plannedWrites, save) + takenIDs[id] = true + result.Created = append(result.Created, id) + case strategy == StrategySkip: + result.Skipped = append(result.Skipped, id) + case strategy == StrategyOverwrite: + plannedWrites = append(plannedWrites, save) + result.Overwritten = append(result.Overwritten, id) + case strategy == StrategyDuplicate: + duplicateID := nextDuplicateIDFromTaken(takenIDs, id) + save.ID = duplicateID + plannedWrites = append(plannedWrites, save) + takenIDs[duplicateID] = true + result.Created = append(result.Created, duplicateID) + } + } + + applied := make([]collectAppliedWrite, 0, len(plannedWrites)) + for _, write := range plannedWrites { + prior, hadPrior := existingByID[write.ID] + applied = append(applied, collectAppliedWrite{ + id: write.ID, + hadPrior: hadPrior, + priorRecord: prior, + }) + + if _, err := store.Put(write); 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{ + ID: write.ID, + RelativePath: write.ID, + Tool: write.Tool, + Event: write.Event, + Matcher: write.Matcher, + Handlers: append([]Handler(nil), write.Handlers...), + } + } + + return result, 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) == "" && strings.ToLower(strings.TrimSpace(item.SourceTool)) != "codex" { + 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), + 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 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), + 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, + Handlers: entry.priorRecord.Handlers, + }); 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 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 000000000..3b13f7d56 --- /dev/null +++ b/internal/resources/hooks/collect_test.go @@ -0,0 +1,462 @@ +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]) + } +} + +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_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 000000000..011b5e1ba --- /dev/null +++ b/internal/resources/hooks/compile.go @@ -0,0 +1,136 @@ +package hooks + +import ( + "fmt" + "path" + "path/filepath" + "sort" + "strings" + + "skillshare/internal/resources/adapters" +) + +type CompiledFile = adapters.CompiledFile + +// CompileTarget compiles managed hook records into target-native files. +func CompileTarget(records []Record, target, projectRoot, rawConfig string) ([]CompiledFile, []string, error) { + target = strings.ToLower(strings.TrimSpace(target)) + if target == "" { + return nil, nil, fmt.Errorf("target is required") + } + + var ( + converted []adapters.HookRecord + warnings []string + ) + + for _, record := range records { + adapterRecord, warn, err := normalizeRecord(record) + if err != nil { + return nil, nil, err + } + if warn != "" { + warnings = append(warnings, warn) + continue + } + if adapterRecord.Tool != target { + 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 target { + case "claude": + files, adapterWarnings, err = adapters.CompileClaudeHooks(converted, projectRoot, rawConfig) + case "codex": + files, adapterWarnings, err = adapters.CompileCodexHooks(converted, projectRoot, rawConfig) + default: + return nil, nil, fmt.Errorf("unsupported target %q", target) + } + if err != nil { + return nil, nil, err + } + + warnings = append(warnings, adapterWarnings...) + return files, warnings, nil +} + +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 == "" && tool != "codex" { + 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), + 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, + Handlers: handlers, + }, "", nil +} diff --git a/internal/resources/hooks/compile_test.go b/internal/resources/hooks/compile_test.go new file mode 100644 index 000000000..cd65b874c --- /dev/null +++ b/internal/resources/hooks/compile_test.go @@ -0,0 +1,68 @@ +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", "/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", "/tmp/project", "") + if err == nil { + t.Fatal("expected invalid managed path error") + } +} + +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 000000000..32673bfba --- /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 000000000..287cab25a --- /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 000000000..6339c23c4 --- /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 000000000..f20558ae1 --- /dev/null +++ b/internal/resources/hooks/store.go @@ -0,0 +1,372 @@ +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"` + Handlers []Handler `yaml:"handlers"` +} + +// 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), + Handlers: sanitizeHandlers(in.Handlers), + }) + 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), + Handlers: sanitizeHandlers(in.Handlers), + }, 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) + } + 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), + Handlers: sanitizeHandlers(file.Handlers), + }, 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) + } + 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), + Handlers: sanitizeHandlers(file.Handlers), + }) + 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, + 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 strings.TrimSpace(in.Event) == "" { + return fmt.Errorf("hook %q: event is required", id) + } + if strings.TrimSpace(in.Matcher) == "" && tool != "codex" { + 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": + 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), + 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 (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 000000000..361d5b5e5 --- /dev/null +++ b/internal/resources/hooks/store_test.go @@ -0,0 +1,345 @@ +package hooks + +import ( + "os" + "path/filepath" + "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_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"}}, + }, + { + ID: "gemini/pre-tool-use/bash.yaml", + Tool: "gemini", + 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_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 000000000..15b10535c --- /dev/null +++ b/internal/resources/hooks/types.go @@ -0,0 +1,32 @@ +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 + Handlers []Handler +} + +// Save is the payload for persisting one managed matcher-group hook. +type Save struct { + ID string + Tool string + Event string + Matcher string + Handlers []Handler +} + +// Handler is one action within a managed matcher-group hook. +type Handler struct { + Type string `yaml:"type"` + 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/rules/collect.go b/internal/resources/rules/collect.go new file mode 100644 index 000000000..2443b0a14 --- /dev/null +++ b/internal/resources/rules/collect.go @@ -0,0 +1,270 @@ +package rules + +import ( + "fmt" + "os" + "path" + "path/filepath" + "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 collectAppliedWrite struct { + id string + hadPriorContent bool + priorContent []byte +} + +func invalidCollectf(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrInvalidCollect, fmt.Sprintf(format, args...)) +} + +// 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 + } + if err := rejectCanonicalManagedIDCollisions(discovered); err != nil { + return CollectResult{}, err + } + + store := NewStore(projectRoot) + existing, err := store.List() + if err != nil { + return CollectResult{}, err + } + + takenIDs := make(map[string]bool, len(existing)+len(discovered)) + for _, record := range existing { + takenIDs[record.ID] = true + } + + type plannedWrite struct { + id string + content []byte + } + plannedWrites := make([]plannedWrite, 0, len(discovered)) + result := CollectResult{} + + for _, item := range discovered { + if !item.Collectible { + reason := strings.TrimSpace(item.CollectReason) + if reason == "" { + reason = "rule is not collectible" + } + return CollectResult{}, invalidCollectf("cannot collect %s: %s", item.Path, reason) + } + + id, err := managedIDForDiscoveredRule(item) + if err != nil { + return CollectResult{}, err + } + exists := takenIDs[id] + + switch { + case !exists: + plannedWrites = append(plannedWrites, plannedWrite{ + id: id, + content: []byte(item.Content), + }) + takenIDs[id] = true + result.Created = append(result.Created, id) + case strategy == StrategySkip: + result.Skipped = append(result.Skipped, id) + case strategy == StrategyOverwrite: + plannedWrites = append(plannedWrites, plannedWrite{ + id: id, + content: []byte(item.Content), + }) + takenIDs[id] = true + result.Overwritten = append(result.Overwritten, id) + case strategy == StrategyDuplicate: + duplicateID := nextDuplicateIDFromTaken(takenIDs, id) + plannedWrites = append(plannedWrites, plannedWrite{ + id: duplicateID, + content: []byte(item.Content), + }) + takenIDs[duplicateID] = true + result.Created = append(result.Created, duplicateID) + } + } + + currentContent := make(map[string][]byte, len(existing)+len(plannedWrites)) + for _, record := range existing { + currentContent[record.ID] = append([]byte(nil), record.Content...) + } + + applied := make([]collectAppliedWrite, 0, len(plannedWrites)) + for _, write := range plannedWrites { + prior, hadPrior := currentContent[write.id] + applied = append(applied, collectAppliedWrite{ + id: write.id, + hadPriorContent: hadPrior, + priorContent: append([]byte(nil), prior...), + }) + + if _, err := store.Put(Save{ID: write.id, Content: write.content}); 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 + } + currentContent[write.id] = append([]byte(nil), write.content...) + } + + return result, 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.hadPriorContent { + if _, err := store.Put(Save{ID: entry.id, Content: entry.priorContent}); 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 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 + } + } +} + +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 + } + } + + 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 000000000..6b1506fad --- /dev/null +++ b/internal/resources/rules/collect_test.go @@ -0,0 +1,272 @@ +package rules + +import ( + "errors" + "os" + "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"), + }) + 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") + } + + 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") + } + + 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_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") + } +} diff --git a/internal/resources/rules/compile.go b/internal/resources/rules/compile.go new file mode 100644 index 000000000..83b0c5744 --- /dev/null +++ b/internal/resources/rules/compile.go @@ -0,0 +1,120 @@ +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, target, projectRoot string) ([]CompiledFile, []string, error) { + target = strings.ToLower(strings.TrimSpace(target)) + if target == "" { + return nil, nil, fmt.Errorf("target is required") + } + + var ( + converted []adapters.RuleRecord + warnings []string + ) + + for _, record := range records { + adapterRecord, warn, err := normalizeRecord(record) + if err != nil { + return nil, nil, err + } + if warn != "" { + warnings = append(warnings, warn) + continue + } + if adapterRecord.Tool != target { + 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 target { + 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) + default: + return nil, nil, fmt.Errorf("%w %q", ErrUnsupportedTarget, target) + } + if err != nil { + return nil, nil, err + } + + warnings = append(warnings, adapterWarnings...) + return files, warnings, nil +} + +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 000000000..6528e09bd --- /dev/null +++ b/internal/resources/rules/compile_test.go @@ -0,0 +1,103 @@ +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")}, + } + + codexFiles, warnings, err := CompileTarget(ruleSet, "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", 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", 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")) +} + +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", 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", 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 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 000000000..e93f4450b --- /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/replace_nonwindows.go b/internal/resources/rules/replace_nonwindows.go new file mode 100644 index 000000000..55a42a104 --- /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 000000000..54a8e983b --- /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 000000000..d58ac6530 --- /dev/null +++ b/internal/resources/rules/store.go @@ -0,0 +1,252 @@ +package rules + +import ( + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "skillshare/internal/config" +) + +var ruleWriteFile = os.WriteFile + +// 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) + } + + 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) + } + + tool, name := splitRuleID(id) + return Record{ + ID: id, + Path: fullPath, + Tool: tool, + RelativePath: id, + Name: name, + Content: append([]byte(nil), in.Content...), + }, 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 + } + tool, name := splitRuleID(cleanedID) + return Record{ + ID: cleanedID, + Path: fullPath, + Tool: tool, + RelativePath: cleanedID, + Name: name, + Content: data, + }, 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()) { + 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) + out = append(out, Record{ + ID: id, + Path: p, + Tool: tool, + RelativePath: id, + Name: name, + Content: data, + }) + 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 + } + return os.Remove(fullPath) +} + +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 strings.HasPrefix(part, ".rule-tmp-") { + 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) + } + + 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) replaceRuleFile(tempPath, fullPath string) error { + return replaceRuleFile(tempPath, fullPath) +} + +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": + return true + default: + return false + } +} + +func isTransientRuleFile(name string) bool { + return strings.HasPrefix(name, ".rule-tmp-") +} + +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 000000000..c74d34ce4 --- /dev/null +++ b/internal/resources/rules/store_test.go @@ -0,0 +1,302 @@ +package rules + +import ( + "os" + "path/filepath" + "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_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`, + } + + 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_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"} { + 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_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 000000000..ea5905502 --- /dev/null +++ b/internal/resources/rules/types.go @@ -0,0 +1,17 @@ +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 +} + +// Save is the input payload for persisting a managed rule. +type Save struct { + ID string + Content []byte +} diff --git a/internal/server/content_stats.go b/internal/server/content_stats.go new file mode 100644 index 000000000..558ab36ff --- /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 000000000..24ecffbe5 --- /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 c4e737549..507cdf52c 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 ecb5ea0e9..6750986eb 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 32db1abb3..b817bf9d4 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 000000000..c51b1dc40 --- /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 000000000..9fc1372f8 --- /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_hooks.go b/internal/server/handler_managed_hooks.go new file mode 100644 index 000000000..c5c632845 --- /dev/null +++ b/internal/server/handler_managed_hooks.go @@ -0,0 +1,653 @@ +package server + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + + "skillshare/internal/config" + "skillshare/internal/inspect" + managedhooks "skillshare/internal/resources/hooks" +) + +type managedHookHandlerPayload struct { + Type string `json:"type"` + 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"` + Handlers []managedHookHandlerPayload `json:"handlers"` +} + +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"` + Handlers []managedHookHandlerPayload `json:"handlers"` +} + +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 + } + + 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(managedhooks.Save{ + ID: canonicalID, + Tool: body.Tool, + Event: body.Event, + Matcher: body.matcher(), + Handlers: body.toHandlers(), + }) + 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 + } + + 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(managedhooks.Save{ + ID: canonicalID, + Tool: body.Tool, + Event: body.Event, + Matcher: body.matcher(), + Handlers: body.toHandlers(), + }) + 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) 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 := managedhooks.Collect(projectRoot, selected, managedhooks.CollectOptions{Strategy: 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, + Handlers: record.Handlers, + }) + 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)) + for _, name := range targetNames { + target := s.cfg.Targets[name] + compileTarget, compileRoot, ok := s.resolveManagedHookPreviewTarget(name, target) + if !ok { + previews = append(previews, managedHookPreview{ + Target: name, + Files: []managedhooks.CompiledFile{}, + Warnings: []string{"unsupported target \"" + name + "\""}, + }) + continue + } + + rawConfig, err := loadManagedHookRawConfig(compileTarget, compileRoot) + if err != nil { + return nil, err + } + files, warnings, err := managedhooks.CompileTarget(records, compileTarget, compileRoot, 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 (s *Server) resolveManagedHookPreviewTarget(name string, target config.TargetConfig) (string, string, bool) { + sc := target.SkillsConfig() + compileTarget, ok := resolveManagedHookPreviewTool(name, sc.Path) + if !ok { + return "", "", false + } + if s.IsProjectMode() { + return compileTarget, s.projectRoot, true + } + return compileTarget, managedHookGlobalPreviewRoot(sc.Path), true +} + +func resolveManagedHookPreviewTool(name, targetPath string) (string, bool) { + for _, supported := range []string{"claude", "codex"} { + if config.MatchesTargetName(supported, name) { + return supported, true + } + } + + switch managedHookPathFamily(targetPath) { + case "claude", "codex": + return managedHookPathFamily(targetPath), true + default: + return "", false + } +} + +func managedHookPathFamily(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" + default: + return "" + } +} + +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": + return filepath.Dir(cleaned) + default: + return cleaned + } +} + +func loadManagedHookRawConfig(target, root string) (string, error) { + path, ok := managedHookConfigPath(target, root) + if !ok { + return "", nil + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + return "", err + } + return string(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 + default: + return "", false + } +} + +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 !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) + return normalizedTool == "codex" && (normalizedEvent == "UserPromptSubmit" || normalizedEvent == "Stop") +} + +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), + 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, + 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, + Handlers: handlers, + } +} + +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.HasPrefix(msg, "invalid collect strategy") || + strings.HasPrefix(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 000000000..f2491c235 --- /dev/null +++ b/internal/server/handler_managed_hooks_test.go @@ -0,0 +1,594 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "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 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":"gemini","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":"gemini","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) + } +} diff --git a/internal/server/handler_managed_rules.go b/internal/server/handler_managed_rules.go new file mode 100644 index 000000000..a00f2855d --- /dev/null +++ b/internal/server/handler_managed_rules.go @@ -0,0 +1,530 @@ +package server + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "skillshare/internal/config" + "skillshare/internal/inspect" + 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"` +} + +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"` +} + +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, managedRulePayload{ + ID: record.ID, + Tool: record.Tool, + Name: record.Name, + RelativePath: record.RelativePath, + Content: string(record.Content), + }) + } + + 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 + } + + 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(managedrules.Save{ + ID: body.ID, + Content: []byte(*body.Content), + }) + 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 + } + + store := managedrules.NewStore(s.managedRulesProjectRoot()) + s.mu.Lock() + if _, err := store.Get(id); err != nil { + s.mu.Unlock() + writeError(w, managedRuleLoadStatus(err), err.Error()) + return + } + + record, err := store.Put(managedrules.Save{ + ID: body.ID, + Content: []byte(*body.Content), + }) + 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) 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 := managedrules.Collect(projectRoot, selected, managedrules.CollectOptions{Strategy: 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": managedRulePayload{ + ID: record.ID, + Tool: record.Tool, + Name: record.Name, + RelativePath: record.RelativePath, + Content: string(record.Content), + }, + "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, 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 := resolveManagedRulePreviewTool(name, sc.Path) + if !ok { + return name, sc.Path + } + + if s.IsProjectMode() { + return compileTarget, s.projectRoot + } + + return compileTarget, managedRuleGlobalPreviewRoot(sc.Path) +} + +func resolveManagedRulePreviewTool(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 managedRuleGlobalPreviewRoot(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 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 + return nil +} + +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 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": + 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 000000000..8480c637b --- /dev/null +++ b/internal/server/handler_managed_rules_test.go @@ -0,0 +1,891 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "skillshare/internal/config" + "skillshare/internal/inspect" +) + +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 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 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) + } +} diff --git a/internal/server/handler_overview.go b/internal/server/handler_overview.go index 2cab0557c..31e8762ff 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 698b0eb27..59c529f56 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 000000000..fce3816f4 --- /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 000000000..96da5cfc1 --- /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 2ca95a65d..352301f5f 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 444529418..a9a024da3 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 611f31b3a..96afbb83b 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,77 @@ 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 { 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 +131,101 @@ 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 + if resources.skills { + sc := target.SkillsConfig() + mode := sc.Mode + if mode == "" { + mode = globalMode } - 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 + + 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) + } - case "copy": - copyResult, err := ssync.SyncTargetCopyWithSkills(name, target, allSkills, s.cfg.Source, body.DryRun, body.Force, nil) + if resources.rules { + res, err := s.syncManagedRulesForTarget(name, target, body.DryRun) 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 - } + results = append(results, res) + } - default: - err := ssync.SyncTarget(name, target, s.cfg.Source, body.DryRun, s.projectRoot) + if resources.hooks { + res, err := s.syncManagedHooksForTarget(name, target, body.DryRun) 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...) - } + results = append(results, res) } } } - // 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 +236,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 db32f0154..d110f2d2b 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,172 @@ 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_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 ffddf388a..d2dd87527 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 000000000..d85397d88 --- /dev/null +++ b/internal/server/managed_resource_sync.go @@ -0,0 +1,249 @@ +package server + +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" + managedrules "skillshare/internal/resources/rules" +) + +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) syncManagedRulesForTarget(name string, target config.TargetConfig, dryRun bool) (syncTargetResult, error) { + result := newSyncTargetResult(name, "rules") + + compileTarget, ok := resolveManagedRulePreviewTool(name, target.SkillsConfig().Path) + if !ok { + return result, nil + } + + store := managedrules.NewStore(s.managedRulesProjectRoot()) + records, err := store.List() + if err != nil { + return result, fmt.Errorf("list managed rules: %w", err) + } + + _, compileRoot := s.resolveManagedRulePreviewTarget(name, target) + files, _, err := managedrules.CompileTarget(records, compileTarget, compileRoot) + if err != nil { + if errors.Is(err, managedrules.ErrUnsupportedTarget) { + return result, nil + } + return result, fmt.Errorf("compile managed rules: %w", err) + } + + updated, skipped, err := apply.CompiledFiles(files, dryRun) + if err != nil { + return result, fmt.Errorf("apply managed rules: %w", err) + } + pruned, err := pruneManagedRuleOrphans(compileTarget, compileRoot, files, dryRun) + if err != nil { + return result, fmt.Errorf("prune managed rules: %w", err) + } + + result.Updated = updated + result.Skipped = skipped + result.Pruned = pruned + return result, nil +} + +func (s *Server) syncManagedHooksForTarget(name string, target config.TargetConfig, dryRun bool) (syncTargetResult, error) { + result := newSyncTargetResult(name, "hooks") + + compileTarget, compileRoot, ok := s.resolveManagedHookPreviewTarget(name, target) + if !ok { + return result, nil + } + + store := managedhooks.NewStore(s.managedHooksProjectRoot()) + records, err := store.List() + if err != nil { + return result, fmt.Errorf("list managed hooks: %w", err) + } + + rawConfig, err := loadManagedHookRawConfig(compileTarget, compileRoot) + if err != nil { + return result, fmt.Errorf("load managed hook config: %w", err) + } + files, _, err := managedhooks.CompileTarget(records, compileTarget, compileRoot, rawConfig) + if err != nil { + return result, fmt.Errorf("compile managed hooks: %w", err) + } + + updated, skipped, err := apply.CompiledFiles(files, dryRun) + if err != nil { + return result, fmt.Errorf("apply managed hooks: %w", err) + } + + result.Updated = updated + result.Skipped = skipped + return result, nil +} + +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/internal/server/server.go b/internal/server/server.go index c48d1188c..eb6317cf8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -370,6 +370,36 @@ 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/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("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("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 000000000..bf6f41a06 --- /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 000000000..17242e9b4 --- /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 6ccad76f1..fa5bba131 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 000000000..bdaf3e8e0 --- /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 9fbaa7387..9e1f5a260 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 ebbde9885..8143ea27f 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,60 @@ 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 }), + }), + 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 }), + }), + 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 +622,8 @@ export interface Overview { targetCount: number; mode: string; version: string; + managedRulesCount?: number; + managedHooksCount?: number; trackedRepos: TrackedRepo[]; isProjectMode: boolean; projectRoot?: string; @@ -583,7 +673,8 @@ export interface TemplatesResponse { export interface CreateSkillRequest { name: string; - pattern: string; + kind?: 'skill' | 'agent'; + pattern?: string; category?: string; scaffoldDirs?: string[]; } @@ -591,6 +682,7 @@ export interface CreateSkillRequest { export interface CreateSkillResponse { skill: { name: string; + kind: 'skill' | 'agent'; flatName: string; relPath: string; sourcePath: string; @@ -598,6 +690,12 @@ export interface CreateSkillResponse { createdFiles: string[]; } +export interface SkillStats { + wordCount: number; + lineCount: number; + tokenCount: number; +} + export interface Target { name: string; path: string; @@ -620,6 +718,7 @@ export interface Target { } export interface SyncResult { + resource: string; target: string; linked: string[]; updated: string[]; @@ -896,7 +995,7 @@ export interface PullResponse { export interface LogEntry { ts: string; cmd: string; - args?: Record; + args?: Record; status: string; msg?: string; ms?: number; @@ -984,6 +1083,132 @@ 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; +} + +export interface ManagedRuleSaveRequest { + tool: string; + relativePath: string; + content: string; +} + +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[]; +} + +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[]; +} + +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 14ecb2a58..14dbaba45 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 000000000..cc133790c --- /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 1d7c3a1aa..5f1d2c47f 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 ada5f84af..581331c13 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 000000000..389edbb27 --- /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 000000000..0837aadda --- /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 && ( + + )} +