From da35c934a0355533f70b47c496e4af2b27b633d7 Mon Sep 17 00:00:00 2001 From: talvak Date: Tue, 21 Apr 2026 16:01:52 +0300 Subject: [PATCH 1/2] feat: add native plugin and hook sync --- README.md | 38 +- cmd/skillshare/diff.go | 24 +- cmd/skillshare/diff_agents.go | 4 +- cmd/skillshare/diff_plugin_hook.go | 64 + cmd/skillshare/diff_plugin_hook_test.go | 52 + cmd/skillshare/diff_project.go | 2 +- cmd/skillshare/doctor.go | 76 +- cmd/skillshare/doctor_json.go | 18 +- .../doctor_json_plugin_hook_test.go | 30 + cmd/skillshare/hooks.go | 215 ++++ cmd/skillshare/hooks_test.go | 119 ++ cmd/skillshare/main.go | 15 +- cmd/skillshare/plugins.go | 262 ++++ cmd/skillshare/status.go | 18 + cmd/skillshare/status_project.go | 16 + cmd/skillshare/sync.go | 74 +- internal/config/basedir_test.go | 40 + internal/config/config.go | 22 + internal/config/plugin_hook_path.go | 97 ++ internal/config/plugin_hook_path_test.go | 22 + internal/hooks/hooks.go | 1115 +++++++++++++++++ internal/hooks/hooks_test.go | 383 ++++++ internal/install/sparse_checkout.go | 5 + internal/plugins/plugins.go | 729 +++++++++++ internal/plugins/plugins_test.go | 219 ++++ internal/server/handler_plugin_hook.go | 160 +++ internal/server/handler_plugin_hook_test.go | 127 ++ internal/server/server.go | 10 + internal/tooling/fs.go | 217 ++++ .../tooling/sandbox_prepare_state_test.go | 96 ++ schemas/config.schema.json | 12 + scripts/prepare_sandbox_host_state.sh | 157 +++ .../docs/reference/appendix/file-structure.md | 112 +- website/docs/reference/commands/diff.md | 12 + website/docs/reference/commands/doctor.md | 24 + website/docs/reference/commands/hooks.md | 243 ++++ website/docs/reference/commands/index.md | 15 + website/docs/reference/commands/plugins.md | 220 ++++ website/docs/reference/commands/status.md | 35 +- website/docs/reference/commands/sync.md | 50 +- .../docs/reference/targets/configuration.md | 39 + .../reference/targets/supported-targets.md | 21 + website/docs/understand/source-and-targets.md | 95 +- website/sidebars.ts | 2 + 44 files changed, 5245 insertions(+), 61 deletions(-) create mode 100644 cmd/skillshare/diff_plugin_hook.go create mode 100644 cmd/skillshare/diff_plugin_hook_test.go create mode 100644 cmd/skillshare/doctor_json_plugin_hook_test.go create mode 100644 cmd/skillshare/hooks.go create mode 100644 cmd/skillshare/hooks_test.go create mode 100644 cmd/skillshare/plugins.go create mode 100644 internal/config/plugin_hook_path.go create mode 100644 internal/config/plugin_hook_path_test.go create mode 100644 internal/hooks/hooks.go create mode 100644 internal/hooks/hooks_test.go create mode 100644 internal/plugins/plugins.go create mode 100644 internal/plugins/plugins_test.go create mode 100644 internal/server/handler_plugin_hook.go create mode 100644 internal/server/handler_plugin_hook_test.go create mode 100644 internal/tooling/fs.go create mode 100644 internal/tooling/sandbox_prepare_state_test.go create mode 100755 scripts/prepare_sandbox_host_state.sh create mode 100644 website/docs/reference/commands/hooks.md create mode 100644 website/docs/reference/commands/plugins.md diff --git a/README.md b/README.md index a7e71200..87ded786 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@

- One source of truth for AI CLI skills, agents, rules, commands & more. Sync everywhere with one command — from personal to organization-wide.
+ One source of truth for AI CLI skills, agents, native plugins, standalone hooks, rules, commands & more. Sync everywhere with one command — from personal to organization-wide.
Codex, Claude Code, OpenClaw, OpenCode & 60+ more.

@@ -51,7 +51,7 @@ skillshare fixes this: - **One source, every agent** — sync to Claude, Cursor, Codex & 60+ more with `skillshare sync` - **Agent management** — sync custom agents alongside skills to agent-capable targets -- **More than skills** — manage rules, commands, prompts & any file-based resource with [extras](https://skillshare.runkids.cc/docs/reference/targets/configuration#extras) +- **More than skills** — manage agents, native plugins, standalone hooks, and file-based resources with [extras](https://skillshare.runkids.cc/docs/reference/targets/configuration#extras) - **Install from anywhere** — GitHub, GitLab, Bitbucket, Azure DevOps, or any self-hosted Git - **Built-in security** — audit skills for prompt injection and data exfiltration before use - **Team-ready** — project skills in `.skillshare/`, org-wide skills via tracked repos @@ -71,6 +71,8 @@ skillshare fixes this: │ ~/.config/skillshare/skills/ ← skills (SKILL.md) │ │ ~/.config/skillshare/agents/ ← agents │ │ ~/.config/skillshare/extras/ ← rules, commands, etc. │ +│ ~/.config/skillshare/plugins/ ← native plugin bundles │ +│ ~/.config/skillshare/hooks/ ← standalone hook bundles │ └─────────────────────────────────────────────────────────────┘ │ sync ┌───────────────┼───────────────┐ @@ -80,10 +82,21 @@ skillshare fixes this: └───────────┘ └───────────┘ └───────────┘ ``` -| Platform | Skills Source | Agents Source | Extras Source | Link Type | -|----------|---------------|---------------|---------------|-----------| -| macOS/Linux | `~/.config/skillshare/skills/` | `~/.config/skillshare/agents/` | `~/.config/skillshare/extras/` | Symlinks | -| Windows | `%AppData%\skillshare\skills\` | `%AppData%\skillshare\agents\` | `%AppData%\skillshare\extras\` | NTFS Junctions (no admin required) | +| Platform | Skills Source | Agents Source | Plugins Source | Hooks Source | Extras Source | Link Type | +|----------|---------------|---------------|----------------|--------------|---------------|-----------| +| macOS/Linux | `~/.config/skillshare/skills/` | `~/.config/skillshare/agents/` | `~/.config/skillshare/plugins/` | `~/.config/skillshare/hooks/` | `~/.config/skillshare/extras/` | Symlinks | +| Windows | `%AppData%\skillshare\skills\` | `%AppData%\skillshare\agents\` | `%AppData%\skillshare\plugins\` | `%AppData%\skillshare\hooks\` | `%AppData%\skillshare\extras\` | NTFS Junctions (no admin required) | + +### Native integrations + +Plugins and hooks are separate from skills, agents, and extras: + +- **Plugins** are native Claude/Codex plugin bundles that render into target-specific marketplace roots and can be enabled during sync. +- **Hooks** are standalone Claude/Codex hook bundles that render scripts into managed hook roots, then merge references back into each tool's config. +- **Scope today** — plugin and hook management currently targets **Claude** and **Codex** only. +- **Web UI** — the current web UI does not yet have dedicated plugin/hook screens; use the CLI or server API surfaces for these resources. + +See the full docs: [Plugins](https://skillshare.runkids.cc/docs/reference/commands/plugins), [Hooks](https://skillshare.runkids.cc/docs/reference/commands/hooks), and [Source & Targets](https://skillshare.runkids.cc/docs/understand/source-and-targets). | | Imperative (install-per-command) | Declarative (skillshare) | |---|---|---| @@ -203,6 +216,16 @@ skillshare extras collect rules # collect local files back to source skillshare completion bash --install # also: zsh, fish, powershell, nushell ``` +**Native plugins and hooks** —manage Claude/Codex native integrations from source + +```bash +skillshare plugins list +skillshare plugins import demo --from claude +skillshare plugins sync --target all +skillshare hooks import --from codex --all +skillshare hooks sync --target claude +``` + **Web dashboard** —visual control panel ```bash @@ -211,6 +234,9 @@ skillshare ui [All commands & guides →](https://skillshare.runkids.cc/docs/reference/commands) +> [!NOTE] +> Validation for plugin/hook docs and runtime flows is safest in an isolated Docker sandbox: mount the repo read-only, copy your `~/.config/skillshare`, `~/.claude`, `~/.codex`, and `~/.agents` state into a disposable sandbox HOME, and run verification there so host config is never mutated. + ## Contributing Contributions welcome! Open an issue first, then submit a draft PR with tests. diff --git a/cmd/skillshare/diff.go b/cmd/skillshare/diff.go index 34e5676f..9f5e5283 100644 --- a/cmd/skillshare/diff.go +++ b/cmd/skillshare/diff.go @@ -28,8 +28,10 @@ type diffRenderOpts struct { // diffJSONOutput is the JSON representation for diff --json output. type diffJSONOutput struct { - Targets []diffJSONTarget `json:"targets"` - Duration string `json:"duration"` + Targets []diffJSONTarget `json:"targets"` + Duration string `json:"duration"` + Plugins []pluginDiffJSONEntry `json:"plugins,omitempty"` + Hooks []hookDiffJSONEntry `json:"hooks,omitempty"` } type diffJSONTarget struct { @@ -483,7 +485,7 @@ func cmdDiffGlobal(targetName string, kind resourceKindFilter, opts diffRenderOp results = mergeAgentDiffsGlobal(cfg, results, targetName) if opts.jsonOutput { - return diffOutputJSONWithExtras(results, extrasResults, start) + return diffOutputJSONWithExtras(results, extrasResults, collectPluginDiff(cfg.EffectivePluginsSource(), ""), collectHookDiff(cfg.EffectiveHooksSource(), ""), start) } if shouldLaunchTUI(opts.noTUI, cfg) && len(results) > 0 { return runDiffTUI(results, extrasResults) @@ -509,9 +511,11 @@ func diffItemToJSON(item copyDiffEntry) diffJSONItem { } } -func diffOutputJSON(results []targetDiffResult, start time.Time) error { +func diffOutputJSON(results []targetDiffResult, pluginResults []pluginDiffJSONEntry, hookResults []hookDiffJSONEntry, start time.Time) error { output := diffJSONOutput{ Duration: formatDuration(start), + Plugins: pluginResults, + Hooks: hookResults, } for _, r := range results { jt := diffJSONTarget{ @@ -530,15 +534,19 @@ func diffOutputJSON(results []targetDiffResult, start time.Time) error { return writeJSON(&output) } -func diffOutputJSONWithExtras(results []targetDiffResult, extrasResults []extraDiffResult, start time.Time) error { +func diffOutputJSONWithExtras(results []targetDiffResult, extrasResults []extraDiffResult, pluginResults []pluginDiffJSONEntry, hookResults []hookDiffJSONEntry, start time.Time) error { type outputWithExtras struct { - Targets []diffJSONTarget `json:"targets"` - Extras []extraDiffJSONEntry `json:"extras,omitempty"` - Duration string `json:"duration"` + Targets []diffJSONTarget `json:"targets"` + Extras []extraDiffJSONEntry `json:"extras,omitempty"` + Plugins []pluginDiffJSONEntry `json:"plugins,omitempty"` + Hooks []hookDiffJSONEntry `json:"hooks,omitempty"` + Duration string `json:"duration"` } o := outputWithExtras{ Duration: formatDuration(start), Extras: extrasDiffToJSON(extrasResults), + Plugins: pluginResults, + Hooks: hookResults, } for _, r := range results { jt := diffJSONTarget{ diff --git a/cmd/skillshare/diff_agents.go b/cmd/skillshare/diff_agents.go index 8db57c61..a4282a8d 100644 --- a/cmd/skillshare/diff_agents.go +++ b/cmd/skillshare/diff_agents.go @@ -45,7 +45,7 @@ func diffProjectAgents(root, targetName string, opts diffRenderOpts, start time. } if opts.jsonOutput { - return diffOutputJSON(results, start) + return diffOutputJSON(results, nil, nil, start) } if len(results) == 0 { @@ -79,7 +79,7 @@ func diffGlobalAgents(cfg *config.Config, targetName string, opts diffRenderOpts } if opts.jsonOutput { - return diffOutputJSON(results, start) + return diffOutputJSON(results, nil, nil, start) } if len(results) == 0 { diff --git a/cmd/skillshare/diff_plugin_hook.go b/cmd/skillshare/diff_plugin_hook.go new file mode 100644 index 00000000..9df5f813 --- /dev/null +++ b/cmd/skillshare/diff_plugin_hook.go @@ -0,0 +1,64 @@ +package main + +import ( + "os" + hookpkg "skillshare/internal/hooks" + pluginpkg "skillshare/internal/plugins" +) + +type pluginDiffJSONEntry struct { + Name string `json:"name"` + Target string `json:"target"` + Synced bool `json:"synced"` + Items []string `json:"items,omitempty"` +} + +type hookDiffJSONEntry struct { + Name string `json:"name"` + Target string `json:"target"` + Synced bool `json:"synced"` + Items []string `json:"items,omitempty"` +} + +func collectPluginDiff(sourceRoot, projectRoot string) []pluginDiffJSONEntry { + bundles, _ := pluginpkg.Discover(sourceRoot) + var out []pluginDiffJSONEntry + for _, bundle := range bundles { + for _, target := range pluginpkg.SupportedTargets(bundle) { + rendered := pluginpkg.RenderRoot(projectRoot, bundle.Name, target) + _, err := os.Stat(rendered) + out = append(out, pluginDiffJSONEntry{ + Name: bundle.Name, + Target: target, + Synced: err == nil, + Items: diffItemsForMissing(err, rendered), + }) + } + } + return out +} + +func collectHookDiff(sourceRoot, projectRoot string) []hookDiffJSONEntry { + bundles, _ := hookpkg.Discover(sourceRoot) + var out []hookDiffJSONEntry + for _, bundle := range bundles { + for _, target := range hookpkg.SupportedTargets(bundle) { + root := hookpkg.RenderRoot(projectRoot, bundle.Name, target) + _, err := os.Stat(root) + out = append(out, hookDiffJSONEntry{ + Name: bundle.Name, + Target: target, + Synced: err == nil, + Items: diffItemsForMissing(err, root), + }) + } + } + return out +} + +func diffItemsForMissing(err error, path string) []string { + if err == nil { + return nil + } + return []string{"missing rendered state: " + path} +} diff --git a/cmd/skillshare/diff_plugin_hook_test.go b/cmd/skillshare/diff_plugin_hook_test.go new file mode 100644 index 00000000..afc30cd1 --- /dev/null +++ b/cmd/skillshare/diff_plugin_hook_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCollectPluginAndHookDiffOnlyIncludesSupportedTargets(t *testing.T) { + root := t.TempDir() + pluginSource := filepath.Join(root, "plugins") + hookSource := filepath.Join(root, "hooks") + + if err := os.MkdirAll(filepath.Join(pluginSource, "claude-only", ".claude-plugin"), 0o755); err != nil { + t.Fatalf("mkdir plugin: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginSource, "claude-only", ".claude-plugin", "plugin.json"), []byte(`{"name":"claude-only"}`), 0o644); err != nil { + t.Fatalf("write plugin manifest: %v", err) + } + if err := os.MkdirAll(filepath.Join(pluginSource, "generated"), 0o755); err != nil { + t.Fatalf("mkdir generated plugin: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginSource, "generated", "skillshare.plugin.yaml"), []byte("shared:\n name: generated\n"), 0o644); err != nil { + t.Fatalf("write sidecar: %v", err) + } + + if err := os.MkdirAll(filepath.Join(hookSource, "claude-only"), 0o755); err != nil { + t.Fatalf("mkdir hook: %v", err) + } + if err := os.WriteFile(filepath.Join(hookSource, "claude-only", "hook.yaml"), []byte("claude:\n events:\n SessionStart:\n - command: \"{HOOK_ROOT}/scripts/start.sh\"\n"), 0o644); err != nil { + t.Fatalf("write hook manifest: %v", err) + } + + pluginDiff := collectPluginDiff(pluginSource, "") + if len(pluginDiff) != 4 { + t.Fatalf("expected 3 plugin diff entries, got %+v", pluginDiff) + } + targets := map[string]bool{} + for _, entry := range pluginDiff { + if entry.Name == "claude-only" { + targets[entry.Target] = true + } + } + if !targets["claude"] || !targets["codex"] { + t.Fatalf("expected generated plugin targets for claude-only bundle, got %+v", pluginDiff) + } + + hookDiff := collectHookDiff(hookSource, "") + if len(hookDiff) != 1 || hookDiff[0].Target != "claude" { + t.Fatalf("expected only claude hook diff entry, got %+v", hookDiff) + } +} diff --git a/cmd/skillshare/diff_project.go b/cmd/skillshare/diff_project.go index a8af0282..dc93284c 100644 --- a/cmd/skillshare/diff_project.go +++ b/cmd/skillshare/diff_project.go @@ -129,7 +129,7 @@ func cmdDiffProject(root, targetName string, kind resourceKindFilter, opts diffR results = mergeAgentDiffsProject(root, results, targetName) if opts.jsonOutput { - return diffOutputJSONWithExtras(results, extrasResults, start) + return diffOutputJSONWithExtras(results, extrasResults, collectPluginDiff(config.PluginsSourceDirProject(root), root), collectHookDiff(config.HooksSourceDirProject(root), root), start) } if shouldLaunchTUI(opts.noTUI, nil) && len(results) > 0 { return runDiffTUI(results, extrasResults) diff --git a/cmd/skillshare/doctor.go b/cmd/skillshare/doctor.go index 859b7532..13475a4d 100644 --- a/cmd/skillshare/doctor.go +++ b/cmd/skillshare/doctor.go @@ -12,7 +12,9 @@ import ( "skillshare/internal/backup" "skillshare/internal/config" + hookpkg "skillshare/internal/hooks" "skillshare/internal/install" + pluginpkg "skillshare/internal/plugins" "skillshare/internal/resource" "skillshare/internal/skillignore" "skillshare/internal/sync" @@ -140,13 +142,17 @@ func cmdDoctorGlobal(jsonMode bool) error { runDoctorChecks(cfg, result, false) checkExtras(cfg.Extras, result, false, cfg.EffectiveSkillsSource(), cfg.EffectiveExtrasSource(), "", "") + checkPlugins(cfg.EffectivePluginsSource(), "", result) + checkHooks(cfg.EffectiveHooksSource(), "", result) + pluginBundles, _ := pluginpkg.Discover(cfg.EffectivePluginsSource()) + hookBundles, _ := hookpkg.Discover(cfg.EffectiveHooksSource()) ui.Header("Storage") checkBackupStatus(result, false, backup.BackupDir()) checkTrashStatus(result, trash.TrashDir()) checkVersionDoctor(cfg, result, false) if jsonMode { - return finalizeDoctorJSON(restoreUI, result, updateCh) + return finalizeDoctorJSON(restoreUI, result, updateCh, pluginBundles, hookBundles) } printUpdateAvailable(<-updateCh) @@ -198,13 +204,17 @@ func cmdDoctorProject(root string, jsonMode bool) error { runDoctorChecks(cfg, result, true) checkExtras(rt.config.Extras, result, true, "", "", root, rt.config.EffectiveExtrasSource(root)) + checkPlugins(config.PluginsSourceDirProject(root), root, result) + checkHooks(config.HooksSourceDirProject(root), root, result) + pluginBundles, _ := pluginpkg.Discover(config.PluginsSourceDirProject(root)) + hookBundles, _ := hookpkg.Discover(config.HooksSourceDirProject(root)) ui.Header("Storage") checkBackupStatus(result, true, "") checkTrashStatus(result, trash.ProjectTrashDir(root)) checkVersionDoctor(cfg, result, true) if jsonMode { - return finalizeDoctorJSON(restoreUI, result, updateCh) + return finalizeDoctorJSON(restoreUI, result, updateCh, pluginBundles, hookBundles) } printUpdateAvailable(<-updateCh) @@ -1109,6 +1119,68 @@ func checkExtras(extras []config.ExtraConfig, result *doctorResult, isProject bo } } +func checkPlugins(sourceRoot, projectRoot string, result *doctorResult) { + bundles, err := pluginpkg.Discover(sourceRoot) + if err != nil { + result.addWarning() + result.addCheck("plugins", checkWarning, fmt.Sprintf("Plugins source unavailable: %v", err), nil) + return + } + if len(bundles) == 0 { + result.addCheck("plugins", checkPass, "No plugin bundles configured", nil) + return + } + var details []string + for _, bundle := range bundles { + targets := pluginpkg.SupportedTargets(bundle) + if len(targets) == 0 { + continue + } + missing := 0 + for _, target := range targets { + if _, err := os.Stat(pluginpkg.RenderRoot(projectRoot, bundle.Name, target)); err != nil { + missing++ + } + } + if missing == len(targets) { + details = append(details, fmt.Sprintf("%s: not rendered", bundle.Name)) + } + } + if len(details) > 0 { + result.addWarning() + result.addCheck("plugins", checkWarning, "Some plugins need sync", details) + return + } + result.addCheck("plugins", checkPass, fmt.Sprintf("All %d plugin bundle(s) rendered", len(bundles)), nil) +} + +func checkHooks(sourceRoot, projectRoot string, result *doctorResult) { + bundles, err := hookpkg.Discover(sourceRoot) + if err != nil { + result.addWarning() + result.addCheck("hooks", checkWarning, fmt.Sprintf("Hooks source unavailable: %v", err), nil) + return + } + if len(bundles) == 0 { + result.addCheck("hooks", checkPass, "No hook bundles configured", nil) + return + } + var details []string + for _, bundle := range bundles { + for _, target := range hookpkg.SupportedTargets(bundle) { + if _, err := os.Stat(hookpkg.RenderRoot(projectRoot, bundle.Name, target)); err != nil { + details = append(details, fmt.Sprintf("%s: %s hooks not rendered", bundle.Name, target)) + } + } + } + if len(details) > 0 { + result.addWarning() + result.addCheck("hooks", checkWarning, "Some hooks need sync", details) + return + } + result.addCheck("hooks", checkPass, fmt.Sprintf("All %d hook bundle(s) rendered", len(bundles)), nil) +} + // checkBackupStatus shows last backup time func checkBackupStatus(result *doctorResult, isProject bool, backupDir string) { if isProject { diff --git a/cmd/skillshare/doctor_json.go b/cmd/skillshare/doctor_json.go index dda3351e..6e83900d 100644 --- a/cmd/skillshare/doctor_json.go +++ b/cmd/skillshare/doctor_json.go @@ -3,6 +3,8 @@ package main import ( "fmt" + hookpkg "skillshare/internal/hooks" + pluginpkg "skillshare/internal/plugins" versioncheck "skillshare/internal/version" ) @@ -23,9 +25,11 @@ type doctorCheck struct { } type doctorOutput struct { - Checks []doctorCheck `json:"checks"` - Summary doctorSummary `json:"summary"` - Version *doctorVersion `json:"version,omitempty"` + Checks []doctorCheck `json:"checks"` + Summary doctorSummary `json:"summary"` + Plugins []pluginpkg.Bundle `json:"plugins,omitempty"` + Hooks []hookpkg.Bundle `json:"hooks,omitempty"` + Version *doctorVersion `json:"version,omitempty"` } type doctorSummary struct { @@ -46,7 +50,7 @@ type doctorVersion struct { // Counts are derived from the checks slice (not from result.errors/warnings) // because check-level counts may differ from the text-mode counters when a // single check function calls addError/addWarning multiple times. -func buildDoctorOutput(result *doctorResult) doctorOutput { +func buildDoctorOutput(result *doctorResult, plugins []pluginpkg.Bundle, hooks []hookpkg.Bundle) doctorOutput { var pass, warnings, errors, info int for _, c := range result.checks { switch c.Status { @@ -69,14 +73,16 @@ func buildDoctorOutput(result *doctorResult) doctorOutput { Errors: errors, Info: info, }, + Plugins: plugins, + Hooks: hooks, } } // finalizeDoctorJSON writes the JSON output and returns an appropriate error. // Shared by cmdDoctorGlobal and cmdDoctorProject. -func finalizeDoctorJSON(restoreUI func(), result *doctorResult, updateCh <-chan *versioncheck.CheckResult) error { +func finalizeDoctorJSON(restoreUI func(), result *doctorResult, updateCh <-chan *versioncheck.CheckResult, plugins []pluginpkg.Bundle, hooks []hookpkg.Bundle) error { restoreUI() - output := buildDoctorOutput(result) + output := buildDoctorOutput(result, plugins, hooks) updateResult := <-updateCh output.Version = &doctorVersion{Current: version} if updateResult != nil && updateResult.UpdateAvailable { diff --git a/cmd/skillshare/doctor_json_plugin_hook_test.go b/cmd/skillshare/doctor_json_plugin_hook_test.go new file mode 100644 index 00000000..e0589ce4 --- /dev/null +++ b/cmd/skillshare/doctor_json_plugin_hook_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "testing" + + hookpkg "skillshare/internal/hooks" + pluginpkg "skillshare/internal/plugins" +) + +func TestBuildDoctorOutputIncludesPluginAndHookSections(t *testing.T) { + result := &doctorResult{ + checks: []doctorCheck{ + {Name: "plugins", Status: checkWarning, Message: "plugins need sync"}, + {Name: "hooks", Status: checkPass, Message: "hooks ok"}, + }, + } + plugins := []pluginpkg.Bundle{{Name: "demo", HasClaude: true}} + hooks := []hookpkg.Bundle{{Name: "audit", Targets: map[string]int{"claude": 1}}} + + output := buildDoctorOutput(result, plugins, hooks) + if len(output.Plugins) != 1 || output.Plugins[0].Name != "demo" { + t.Fatalf("expected plugins section, got %+v", output.Plugins) + } + if len(output.Hooks) != 1 || output.Hooks[0].Name != "audit" { + t.Fatalf("expected hooks section, got %+v", output.Hooks) + } + if output.Summary.Warnings != 1 || output.Summary.Pass != 1 { + t.Fatalf("unexpected summary: %+v", output.Summary) + } +} diff --git a/cmd/skillshare/hooks.go b/cmd/skillshare/hooks.go new file mode 100644 index 00000000..4e6e7dc8 --- /dev/null +++ b/cmd/skillshare/hooks.go @@ -0,0 +1,215 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "skillshare/internal/config" + hookpkg "skillshare/internal/hooks" + "skillshare/internal/ui" +) + +func cmdHooks(args []string) error { + if len(args) == 0 { + printHooksHelp() + return nil + } + switch args[0] { + case "list", "ls": + return cmdHooksList(args[1:]) + case "sync": + return cmdHooksSync(args[1:]) + case "import": + return cmdHooksImport(args[1:]) + case "--help", "-h": + printHooksHelp() + return nil + default: + return fmt.Errorf("unknown hooks subcommand: %s", args[0]) + } +} + +func cmdHooksList(args []string) error { + mode, rest, err := parseModeArgs(args) + if err != nil { + return err + } + jsonOutput := hasFlag(rest, "--json") + sourceRoot, _, err := hookRoots(mode) + if err != nil { + return err + } + bundles, err := hookpkg.Discover(sourceRoot) + if err != nil { + return err + } + if jsonOutput { + data, _ := json.MarshalIndent(bundles, "", " ") + fmt.Println(string(data)) + return nil + } + if len(bundles) == 0 { + ui.Info("No hooks found in %s", shortenPath(sourceRoot)) + return nil + } + ui.Header(ui.WithModeLabel("Hooks")) + for _, bundle := range bundles { + ui.Info("%s claude=%d codex=%d", bundle.Name, bundle.Targets["claude"], bundle.Targets["codex"]) + } + return nil +} + +func cmdHooksImport(args []string) error { + mode, rest, err := parseModeArgs(args) + if err != nil { + return err + } + from := "" + all := false + ownedOnly := false + for i := 0; i < len(rest); i++ { + switch rest[i] { + case "--from": + if i+1 >= len(rest) { + return fmt.Errorf("--from requires a value") + } + from = rest[i+1] + i++ + case "--all": + all = true + case "--owned-only": + ownedOnly = true + } + } + if from == "" { + return fmt.Errorf("usage: skillshare hooks import --from claude|codex [--all|--owned-only]") + } + sourceRoot, projectRoot, err := hookRoots(mode) + if err != nil { + return err + } + if err := os.MkdirAll(sourceRoot, 0755); err != nil { + return err + } + bundles, err := hookpkg.Import(sourceRoot, hookpkg.ImportOptions{ + From: from, + Project: projectRoot, + All: all, + OwnedOnly: ownedOnly, + }) + if err != nil { + return err + } + ui.Success("Imported %d hook bundle(s)", len(bundles)) + return nil +} + +func cmdHooksSync(args []string) error { + mode, rest, err := parseModeArgs(args) + if err != nil { + return err + } + target := "all" + jsonOutput := hasFlag(rest, "--json") + var names []string + for i := 0; i < len(rest); i++ { + switch rest[i] { + case "--target": + if i+1 < len(rest) { + target = rest[i+1] + i++ + } + case "--json": + default: + names = append(names, rest[i]) + } + } + sourceRoot, projectRoot, err := hookRoots(mode) + if err != nil { + return err + } + var results []hookpkg.SyncResult + if len(names) > 0 { + want := map[string]bool{} + for _, name := range names { + want[name] = true + } + bundles, err := hookpkg.Discover(sourceRoot) + if err != nil { + return err + } + for _, bundle := range bundles { + if !want[bundle.Name] { + continue + } + for _, one := range []string{target} { + if target == "" || target == "all" { + for _, expanded := range []string{"claude", "codex"} { + res, err := hookpkg.SyncBundle(bundle, projectRoot, expanded) + if err != nil { + return err + } + results = append(results, res) + } + continue + } + res, err := hookpkg.SyncBundle(bundle, projectRoot, one) + if err != nil { + return err + } + results = append(results, res) + } + } + } else { + results, err = hookpkg.SyncAll(sourceRoot, projectRoot, target) + if err != nil { + return err + } + } + if jsonOutput { + return writeJSON(map[string]any{"hooks": results}) + } + ui.Header(ui.WithModeLabel("Syncing hooks")) + for _, res := range results { + if res.Root == "" { + for _, warning := range res.Warnings { + ui.Info("%s: %s", res.Name, warning) + } + continue + } + ui.Success("%s -> %s", res.Name, shortenPath(res.Root)) + for _, warning := range res.Warnings { + ui.Info(" %s", warning) + } + } + return nil +} + +func printHooksHelp() { + fmt.Println(`Usage: skillshare hooks [options] + +Commands: + list [--json] + import --from claude|codex [--all|--owned-only] + sync [name...] --target claude|codex|all [--json]`) +} + +func hookRoots(mode runMode) (sourceRoot, projectRoot string, err error) { + cwd, _ := os.Getwd() + if mode == modeAuto { + if projectConfigExists(cwd) { + mode = modeProject + } else { + mode = modeGlobal + } + } + if mode == modeProject { + return config.HooksSourceDirProject(cwd), cwd, nil + } + cfg, err := config.Load() + if err != nil { + return "", "", err + } + return cfg.EffectiveHooksSource(), "", nil +} diff --git a/cmd/skillshare/hooks_test.go b/cmd/skillshare/hooks_test.go new file mode 100644 index 00000000..7a2b4fb7 --- /dev/null +++ b/cmd/skillshare/hooks_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCmdHooksSyncFiltersByName(t *testing.T) { + root := t.TempDir() + oldWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(oldWD) + }) + + if err := os.MkdirAll(filepath.Join(root, ".skillshare", "hooks", "audit", "scripts"), 0o755); err != nil { + t.Fatalf("mkdir audit: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, ".skillshare", "hooks", "notify", "scripts"), 0o755); err != nil { + t.Fatalf("mkdir notify: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, ".claude"), 0o755); err != nil { + t.Fatalf("mkdir claude: %v", err) + } + if err := os.WriteFile(filepath.Join(root, ".claude", "settings.json"), []byte(`{}`), 0o644); err != nil { + t.Fatalf("write settings: %v", err) + } + if err := os.WriteFile(filepath.Join(root, ".skillshare", "hooks", "audit", "hook.yaml"), []byte("name: audit\nclaude:\n events:\n PreToolUse:\n - command: \"{HOOK_ROOT}/scripts/pre.sh\"\n"), 0o644); err != nil { + t.Fatalf("write audit hook: %v", err) + } + if err := os.WriteFile(filepath.Join(root, ".skillshare", "hooks", "notify", "hook.yaml"), []byte("name: notify\nclaude:\n events:\n PreToolUse:\n - command: \"{HOOK_ROOT}/scripts/pre.sh\"\n"), 0o644); err != nil { + t.Fatalf("write notify hook: %v", err) + } + if err := os.WriteFile(filepath.Join(root, ".skillshare", "hooks", "audit", "scripts", "pre.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write audit script: %v", err) + } + if err := os.WriteFile(filepath.Join(root, ".skillshare", "hooks", "notify", "scripts", "pre.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write notify script: %v", err) + } + + output := captureStdout(t, func() { + if err := cmdHooksSync([]string{"-p", "audit", "--target", "claude", "--json"}); err != nil { + t.Fatalf("cmdHooksSync: %v", err) + } + }) + + var payload struct { + Hooks []struct { + Name string `json:"name"` + Target string `json:"target"` + Root string `json:"root"` + } `json:"hooks"` + } + if err := json.Unmarshal([]byte(output), &payload); err != nil { + t.Fatalf("unmarshal output: %v\n%s", err, output) + } + if len(payload.Hooks) != 1 || payload.Hooks[0].Name != "audit" || payload.Hooks[0].Target != "claude" { + t.Fatalf("unexpected hooks payload: %+v", payload.Hooks) + } + if strings.Contains(output, "notify") { + t.Fatalf("expected filtered sync output, got %s", output) + } + if _, err := os.Stat(filepath.Join(root, ".claude", "hooks", "skillshare", "audit", "scripts", "pre.sh")); err != nil { + t.Fatalf("audit hook not rendered: %v", err) + } + if _, err := os.Stat(filepath.Join(root, ".claude", "hooks", "skillshare", "notify")); !os.IsNotExist(err) { + t.Fatalf("notify hook should not be rendered, err=%v", err) + } +} + +func TestCmdHooksSyncTextShowsWarningsForRenderedBundles(t *testing.T) { + root := t.TempDir() + oldWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(oldWD) + }) + + if err := os.MkdirAll(filepath.Join(root, ".skillshare", "hooks", "audit", "scripts"), 0o755); err != nil { + t.Fatalf("mkdir audit: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, ".codex"), 0o755); err != nil { + t.Fatalf("mkdir codex: %v", err) + } + if err := os.WriteFile(filepath.Join(root, ".codex", "hooks.json"), []byte(`{}`), 0o644); err != nil { + t.Fatalf("write codex hooks: %v", err) + } + if err := os.WriteFile(filepath.Join(root, ".skillshare", "hooks", "audit", "hook.yaml"), []byte("name: audit\ncodex:\n events:\n UnsupportedEvent:\n - command: \"{HOOK_ROOT}/scripts/pre.sh\"\n"), 0o644); err != nil { + t.Fatalf("write audit hook: %v", err) + } + if err := os.WriteFile(filepath.Join(root, ".skillshare", "hooks", "audit", "scripts", "pre.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write audit script: %v", err) + } + + output := stripANSIWarnings(captureStdout(t, func() { + if err := cmdHooksSync([]string{"-p", "audit", "--target", "codex"}); err != nil { + t.Fatalf("cmdHooksSync: %v", err) + } + })) + if !strings.Contains(output, "audit ->") { + t.Fatalf("expected rendered row, got %s", output) + } + if !strings.Contains(output, "unsupported codex event UnsupportedEvent") { + t.Fatalf("expected warning output, got %s", output) + } +} diff --git a/cmd/skillshare/main.go b/cmd/skillshare/main.go index c5748c7f..8ecdaa5d 100644 --- a/cmd/skillshare/main.go +++ b/cmd/skillshare/main.go @@ -45,6 +45,8 @@ var commands = map[string]func([]string) error{ "ui": cmdUI, "tui": cmdTUIToggle, "extras": cmdExtras, + "plugins": cmdPlugins, + "hooks": cmdHooks, "enable": cmdEnable, "disable": cmdDisable, "completion": cmdCompletion, @@ -252,6 +254,17 @@ func printUsage() { cmd("extras", "collect ", "Collect local files into extras source") fmt.Println() + // Plugins & Hooks + fmt.Println("PLUGINS & HOOKS") + cmd("plugins", "list", "List plugin bundles in the Skillshare source") + cmd("plugins", "import --from ", "Import a native plugin bundle into Skillshare") + cmd("plugins", "install --from ", "Import, render, and install a plugin bundle") + cmd("plugins", "sync", "Render/install plugins for Claude and/or Codex") + cmd("hooks", "list", "List standalone hook bundles in the Skillshare source") + cmd("hooks", "import --from ", "Import local Claude/Codex hooks into standalone bundles") + cmd("hooks", "sync", "Render/install standalone hooks for Claude/Codex") + fmt.Println() + // Git Remote fmt.Println("GIT REMOTE") cmd("push", "", "Commit and push source to git remote") @@ -282,7 +295,7 @@ 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 # Sync skills + agents + extras + plugins + hooks") fmt.Println(" skillshare list --all # List skills + agents") fmt.Println(" skillshare collect claude # Import local skills") fmt.Println(" skillshare install anthropics/skills/pdf -p # Project install") diff --git a/cmd/skillshare/plugins.go b/cmd/skillshare/plugins.go new file mode 100644 index 00000000..883dbaf0 --- /dev/null +++ b/cmd/skillshare/plugins.go @@ -0,0 +1,262 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "skillshare/internal/config" + pluginpkg "skillshare/internal/plugins" + "skillshare/internal/ui" +) + +func cmdPlugins(args []string) error { + if len(args) == 0 { + printPluginsHelp() + return nil + } + switch args[0] { + case "list", "ls": + return cmdPluginsList(args[1:]) + case "import": + return cmdPluginsImport(args[1:]) + case "sync": + return cmdPluginsSync(args[1:]) + case "install": + return cmdPluginsInstall(args[1:]) + case "--help", "-h": + printPluginsHelp() + return nil + default: + return fmt.Errorf("unknown plugins subcommand: %s", args[0]) + } +} + +func cmdPluginsList(args []string) error { + mode, rest, err := parseModeArgs(args) + if err != nil { + return err + } + jsonOutput := hasFlag(rest, "--json") + sourceRoot, _, err := pluginRoots(mode) + if err != nil { + return err + } + bundles, err := pluginpkg.Discover(sourceRoot) + if err != nil { + return err + } + if jsonOutput { + data, _ := json.MarshalIndent(bundles, "", " ") + fmt.Println(string(data)) + return nil + } + if len(bundles) == 0 { + ui.Info("No plugins found in %s", shortenPath(sourceRoot)) + return nil + } + ui.Header(ui.WithModeLabel("Plugins")) + for _, bundle := range bundles { + ui.Info("%s claude=%t codex=%t", bundle.Name, bundle.HasClaude, bundle.HasCodex) + } + return nil +} + +func cmdPluginsImport(args []string) error { + mode, rest, err := parseModeArgs(args) + if err != nil { + return err + } + from := "" + filtered := make([]string, 0, len(rest)) + for i := 0; i < len(rest); i++ { + switch rest[i] { + case "--from": + if i+1 >= len(rest) { + return fmt.Errorf("--from requires a value") + } + from = rest[i+1] + i++ + default: + filtered = append(filtered, rest[i]) + } + } + rest = filtered + if len(rest) == 0 { + return fmt.Errorf("usage: skillshare plugins import --from claude|codex") + } + sourceRoot, _, err := pluginRoots(mode) + if err != nil { + return err + } + if err := os.MkdirAll(sourceRoot, 0755); err != nil { + return err + } + bundle, err := pluginpkg.Import(sourceRoot, rest[0], pluginpkg.ImportOptions{From: from}) + if err != nil { + return err + } + ui.Success("Imported plugin %s into %s", bundle.Name, shortenPath(sourceRoot)) + return nil +} + +func cmdPluginsSync(args []string) error { + mode, rest, err := parseModeArgs(args) + if err != nil { + return err + } + target := "all" + install := true + jsonOutput := hasFlag(rest, "--json") + var names []string + for i := 0; i < len(rest); i++ { + switch rest[i] { + case "--target": + if i+1 >= len(rest) { + return fmt.Errorf("--target requires a value") + } + target = rest[i+1] + i++ + case "--no-install": + install = false + case "--json": + default: + names = append(names, rest[i]) + } + } + sourceRoot, projectRoot, err := pluginRoots(mode) + if err != nil { + return err + } + if len(names) > 0 { + want := map[string]bool{} + for _, name := range names { + want[name] = true + } + bundles, err := pluginpkg.Discover(sourceRoot) + if err != nil { + return err + } + var results []pluginpkg.SyncResult + for _, bundle := range bundles { + if !want[bundle.Name] { + continue + } + for _, one := range []string{target} { + if target == "" || target == "all" { + for _, expanded := range []string{"claude", "codex"} { + res, err := pluginpkg.SyncBundle(bundle, projectRoot, expanded, install) + if err != nil { + return err + } + results = append(results, res) + } + continue + } + res, err := pluginpkg.SyncBundle(bundle, projectRoot, one, install) + if err != nil { + return err + } + results = append(results, res) + } + } + return renderPluginSyncResults(results, jsonOutput) + } + results, err := pluginpkg.SyncAll(sourceRoot, projectRoot, target, install) + if err != nil { + return err + } + return renderPluginSyncResults(results, jsonOutput) +} + +func cmdPluginsInstall(args []string) error { + mode, rest, err := parseModeArgs(args) + if err != nil { + return err + } + from := "" + filtered := make([]string, 0, len(rest)) + for i := 0; i < len(rest); i++ { + switch rest[i] { + case "--from": + if i+1 >= len(rest) { + return fmt.Errorf("--from requires a value") + } + from = rest[i+1] + i++ + default: + filtered = append(filtered, rest[i]) + } + } + rest = filtered + if len(rest) == 0 { + return fmt.Errorf("usage: skillshare plugins install --from claude|codex [--target claude|codex|all]") + } + sourceRoot, projectRoot, err := pluginRoots(mode) + if err != nil { + return err + } + if err := os.MkdirAll(sourceRoot, 0755); err != nil { + return err + } + bundle, err := pluginpkg.Import(sourceRoot, rest[0], pluginpkg.ImportOptions{From: from}) + if err != nil { + return err + } + target := "all" + for i := 0; i < len(rest); i++ { + if rest[i] == "--target" && i+1 < len(rest) { + target = rest[i+1] + i++ + } + } + _, err = pluginpkg.SyncAll(sourceRoot, projectRoot, target, true) + if err != nil { + return err + } + ui.Success("Installed plugin %s", bundle.Name) + return nil +} + +func printPluginsHelp() { + fmt.Println(`Usage: skillshare plugins [options] + +Commands: + list [--json] + import --from claude|codex + sync [--target claude|codex|all] [--no-install] [--json] + install --from claude|codex [--target claude|codex|all]`) +} + +func renderPluginSyncResults(results []pluginpkg.SyncResult, jsonOutput bool) error { + if jsonOutput { + return writeJSON(map[string]any{"plugins": results}) + } + ui.Header(ui.WithModeLabel("Syncing plugins")) + for _, res := range results { + ui.Success("%s -> %s", res.Name, shortenPath(res.Rendered)) + for _, warning := range res.Warnings { + ui.Info(" %s", warning) + } + } + return nil +} + +func pluginRoots(mode runMode) (sourceRoot, projectRoot string, err error) { + cwd, _ := os.Getwd() + if mode == modeAuto { + if projectConfigExists(cwd) { + mode = modeProject + } else { + mode = modeGlobal + } + } + if mode == modeProject { + return config.PluginsSourceDirProject(cwd), cwd, nil + } + cfg, err := config.Load() + if err != nil { + return "", "", err + } + return cfg.EffectivePluginsSource(), "", nil +} diff --git a/cmd/skillshare/status.go b/cmd/skillshare/status.go index 58460e39..eb68b209 100644 --- a/cmd/skillshare/status.go +++ b/cmd/skillshare/status.go @@ -11,6 +11,8 @@ import ( "skillshare/internal/audit" "skillshare/internal/config" "skillshare/internal/git" + hookpkg "skillshare/internal/hooks" + pluginpkg "skillshare/internal/plugins" "skillshare/internal/resource" "skillshare/internal/skillignore" "skillshare/internal/sync" @@ -25,6 +27,8 @@ type statusJSONOutput struct { TrackedRepos []statusJSONRepo `json:"tracked_repos"` Targets []statusJSONTarget `json:"targets"` Agents *statusJSONAgents `json:"agents,omitempty"` + Plugins []pluginpkg.Bundle `json:"plugins,omitempty"` + Hooks []hookpkg.Bundle `json:"hooks,omitempty"` Audit statusJSONAudit `json:"audit"` Version string `json:"version"` } @@ -134,6 +138,18 @@ func cmdStatus(args []string) error { return config.ResolveExtrasSourceDir(extra, cfg.EffectiveExtrasSource(), cfg.EffectiveSkillsSource()) }) } + if bundles, bundleErr := pluginpkg.Discover(cfg.EffectivePluginsSource()); bundleErr == nil && len(bundles) > 0 { + ui.Header("Plugins") + for _, bundle := range bundles { + ui.Status(bundle.Name, "plugin", fmt.Sprintf("claude=%t codex=%t", bundle.HasClaude, bundle.HasCodex)) + } + } + if bundles, bundleErr := hookpkg.Discover(cfg.EffectiveHooksSource()); bundleErr == nil && len(bundles) > 0 { + ui.Header("Hooks") + for _, bundle := range bundles { + ui.Status(bundle.Name, "hook", fmt.Sprintf("claude=%d codex=%d", bundle.Targets["claude"], bundle.Targets["codex"])) + } + } printAuditStatus(cfg.Audit) checkSkillVersion(cfg) @@ -185,6 +201,8 @@ func cmdStatus(args []string) error { } output.Agents = buildAgentStatusJSON(cfg) + output.Plugins, _ = pluginpkg.Discover(cfg.EffectivePluginsSource()) + output.Hooks, _ = hookpkg.Discover(cfg.EffectiveHooksSource()) return writeJSON(&output) } diff --git a/cmd/skillshare/status_project.go b/cmd/skillshare/status_project.go index 2be23651..362fdf62 100644 --- a/cmd/skillshare/status_project.go +++ b/cmd/skillshare/status_project.go @@ -9,6 +9,8 @@ import ( "skillshare/internal/audit" "skillshare/internal/config" "skillshare/internal/git" + hookpkg "skillshare/internal/hooks" + pluginpkg "skillshare/internal/plugins" "skillshare/internal/resource" "skillshare/internal/skillignore" "skillshare/internal/sync" @@ -48,6 +50,18 @@ func cmdStatusProject(root string) error { return config.ExtrasSourceDirProject(runtime.config.EffectiveExtrasSource(root), extra.Name) }) } + if bundles, bundleErr := pluginpkg.Discover(config.PluginsSourceDirProject(root)); bundleErr == nil && len(bundles) > 0 { + ui.Header("Plugins") + for _, bundle := range bundles { + ui.Status(bundle.Name, "plugin", fmt.Sprintf("claude=%t codex=%t", bundle.HasClaude, bundle.HasCodex)) + } + } + if bundles, bundleErr := hookpkg.Discover(config.HooksSourceDirProject(root)); bundleErr == nil && len(bundles) > 0 { + ui.Header("Hooks") + for _, bundle := range bundles { + ui.Status(bundle.Name, "hook", fmt.Sprintf("claude=%d codex=%d", bundle.Targets["claude"], bundle.Targets["codex"])) + } + } printAuditStatus(runtime.config.Audit) @@ -117,6 +131,8 @@ func cmdStatusProjectJSON(root string) error { } output.Agents = buildProjectAgentStatusJSON(runtime) + output.Plugins, _ = pluginpkg.Discover(config.PluginsSourceDirProject(root)) + output.Hooks, _ = hookpkg.Discover(config.HooksSourceDirProject(root)) return writeJSON(&output) } diff --git a/cmd/skillshare/sync.go b/cmd/skillshare/sync.go index b23ab81e..484a2f22 100644 --- a/cmd/skillshare/sync.go +++ b/cmd/skillshare/sync.go @@ -9,7 +9,9 @@ import ( "skillshare/internal/backup" "skillshare/internal/config" + hookpkg "skillshare/internal/hooks" "skillshare/internal/oplog" + pluginpkg "skillshare/internal/plugins" "skillshare/internal/skillignore" "skillshare/internal/sync" "skillshare/internal/trash" @@ -39,6 +41,8 @@ type syncJSONOutput struct { Details []syncJSONTargetDetail `json:"details"` Extras []syncExtrasJSONEntry `json:"extras,omitempty"` ContextCost *contextCostJSON `json:"context_cost,omitempty"` + Plugins []pluginpkg.SyncResult `json:"plugins,omitempty"` + Hooks []hookpkg.SyncResult `json:"hooks,omitempty"` } type syncJSONTargetDetail struct { @@ -126,6 +130,12 @@ func cmdSync(args []string) error { if extrasErr := cmdSyncExtras(append([]string{"-p"}, rest...)); extrasErr != nil { ui.Warning("Extras sync: %v", extrasErr) } + if pluginErr := cmdPluginsSync([]string{"-p", "--target", "all"}); pluginErr != nil { + ui.Warning("Plugins sync: %v", pluginErr) + } + if hooksErr := cmdHooksSync([]string{"-p", "--target", "all"}); hooksErr != nil { + ui.Warning("Hooks sync: %v", hooksErr) + } }() } @@ -141,17 +151,33 @@ func cmdSync(args []string) error { } if jsonOutput { + var extrasEntries []syncExtrasJSONEntry + var pluginResults []pluginpkg.SyncResult + var hookResults []hookpkg.SyncResult if hasAll { projCfg, loadErr := config.LoadProject(cwd) if loadErr == nil && len(projCfg.Extras) > 0 { agentPaths := collectAgentTargetPathsProject(cwd) - extrasEntries := runExtrasSyncEntries(projCfg.Extras, func(extra config.ExtraConfig) string { + extrasEntries = runExtrasSyncEntries(projCfg.Extras, func(extra config.ExtraConfig) string { return config.ExtrasSourceDirProject(projCfg.EffectiveExtrasSource(cwd), extra.Name) }, dryRun, force, cwd, agentPaths) - return syncOutputJSON(results, dryRun, start, projIgnoreStats, err, projCtxCost, extrasEntries) + } + pluginRoot, projectRoot, pluginRootErr := pluginRoots(modeProject) + if pluginRootErr == nil { + pluginResults, pluginRootErr = pluginpkg.SyncAll(pluginRoot, projectRoot, "all", true) + } + if pluginRootErr != nil && err == nil { + err = pluginRootErr + } + hookRoot, hookProjectRoot, hookRootErr := hookRoots(modeProject) + if hookRootErr == nil { + hookResults, hookRootErr = hookpkg.SyncAll(hookRoot, hookProjectRoot, "all") + } + if hookRootErr != nil && err == nil { + err = hookRootErr } } - return syncOutputJSON(results, dryRun, start, projIgnoreStats, err, projCtxCost) + return syncOutputJSON(results, dryRun, start, projIgnoreStats, err, projCtxCost, extrasEntries, pluginResults, hookResults) } return err } @@ -294,14 +320,32 @@ func cmdSync(args []string) error { if analyzeErr == nil && len(analyzeEntries) > 0 { ctxCost = buildContextCostJSON(analyzeEntries, cfg.ContextBudget) } + var extrasEntries []syncExtrasJSONEntry + var pluginResults []pluginpkg.SyncResult + var hookResults []hookpkg.SyncResult if hasAll && len(cfg.Extras) > 0 { agentPaths := collectAgentTargetPathsGlobal(cfg) - extrasEntries := runExtrasSyncEntries(cfg.Extras, func(extra config.ExtraConfig) string { + extrasEntries = runExtrasSyncEntries(cfg.Extras, func(extra config.ExtraConfig) string { return config.ResolveExtrasSourceDir(extra, cfg.EffectiveExtrasSource(), cfg.EffectiveSkillsSource()) }, dryRun, force, "", agentPaths) - return syncOutputJSON(results, dryRun, start, ignoreStats, syncErr, ctxCost, extrasEntries) } - return syncOutputJSON(results, dryRun, start, ignoreStats, syncErr, ctxCost) + if hasAll { + pluginRoot, _, pErr := pluginRoots(modeGlobal) + if pErr == nil { + pluginResults, pErr = pluginpkg.SyncAll(pluginRoot, "", "all", true) + } + if pErr != nil && syncErr == nil { + syncErr = pErr + } + hookRoot, _, hErr := hookRoots(modeGlobal) + if hErr == nil { + hookResults, hErr = hookpkg.SyncAll(hookRoot, "", "all") + } + if hErr != nil && syncErr == nil { + syncErr = hErr + } + } + return syncOutputJSON(results, dryRun, start, ignoreStats, syncErr, ctxCost, extrasEntries, pluginResults, hookResults) } // Agent sync when kind=all or --all (after skill sync) @@ -315,6 +359,12 @@ func cmdSync(args []string) error { if extrasErr := cmdSyncExtras(append([]string{"-g"}, rest...)); extrasErr != nil { ui.Warning("Extras sync: %v", extrasErr) } + if pluginErr := cmdPluginsSync([]string{"-g", "--target", "all"}); pluginErr != nil { + ui.Warning("Plugins sync: %v", pluginErr) + } + if hooksErr := cmdHooksSync([]string{"-g", "--target", "all"}); hooksErr != nil { + ui.Warning("Hooks sync: %v", hooksErr) + } } return syncErr @@ -405,7 +455,7 @@ func printIgnoredSkills(stats *skillignore.IgnoreStats) { // syncOutputJSON converts sync results to JSON and writes to stdout. // extras is optional and included when --all is used. -func syncOutputJSON(results []syncTargetResult, dryRun bool, start time.Time, iStats *skillignore.IgnoreStats, syncErr error, ctxCost *contextCostJSON, extras ...[]syncExtrasJSONEntry) error { +func syncOutputJSON(results []syncTargetResult, dryRun bool, start time.Time, iStats *skillignore.IgnoreStats, syncErr error, ctxCost *contextCostJSON, extras []syncExtrasJSONEntry, plugins []pluginpkg.SyncResult, hooks []hookpkg.SyncResult) error { var totals syncModeStats var details []syncJSONTargetDetail for _, r := range results { @@ -439,10 +489,10 @@ func syncOutputJSON(results []syncTargetResult, dryRun bool, start time.Time, iS } output.IgnoredCount = len(ignoredSkills) output.IgnoredSkills = ignoredSkills - if len(extras) > 0 && extras[0] != nil { - output.Extras = extras[0] - } output.ContextCost = ctxCost + output.Extras = extras + output.Plugins = plugins + output.Hooks = hooks return writeJSONResult(&output, syncErr) } @@ -842,7 +892,7 @@ func printSyncHelp() { Sync skills from source to all configured targets. Options: - --all Sync skills, agents, and extras + --all Sync skills, agents, extras, plugins, and hooks --dry-run, -n Preview changes without applying --force, -f Force sync (overwrite local changes) --json Output results as JSON @@ -857,7 +907,7 @@ Subcommands: Examples: skillshare sync Sync skills to all targets skillshare sync --dry-run Preview sync changes - skillshare sync --all Sync skills, agents, and extras + skillshare sync --all Sync skills, agents, extras, plugins, and hooks skillshare sync -p Sync project-level skills skillshare sync agents Sync agents only`) } diff --git a/internal/config/basedir_test.go b/internal/config/basedir_test.go index 06b1e0b9..cb319a04 100644 --- a/internal/config/basedir_test.go +++ b/internal/config/basedir_test.go @@ -147,6 +147,46 @@ func TestEffectiveExtrasSource_SourcesPrefersOverLegacy(t *testing.T) { } } +func TestEffectivePluginsSource_Default(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + cfg := &Config{} + + got := cfg.EffectivePluginsSource() + want := filepath.Join(BaseDir(), "plugins") + if got != want { + t.Errorf("EffectivePluginsSource() = %q, want %q", got, want) + } +} + +func TestEffectivePluginsSource_Explicit(t *testing.T) { + cfg := &Config{PluginsSource: "/custom/plugins"} + + got := cfg.EffectivePluginsSource() + if got != "/custom/plugins" { + t.Errorf("EffectivePluginsSource() = %q, want %q", got, "/custom/plugins") + } +} + +func TestEffectiveHooksSource_Default(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + cfg := &Config{} + + got := cfg.EffectiveHooksSource() + want := filepath.Join(BaseDir(), "hooks") + if got != want { + t.Errorf("EffectiveHooksSource() = %q, want %q", got, want) + } +} + +func TestEffectiveHooksSource_Explicit(t *testing.T) { + cfg := &Config{HooksSource: "/custom/hooks"} + + got := cfg.EffectiveHooksSource() + if got != "/custom/hooks" { + t.Errorf("EffectiveHooksSource() = %q, want %q", got, "/custom/hooks") + } +} + func TestConfigPath_SKILLSHARECONFIGTakesPriority(t *testing.T) { t.Setenv("SKILLSHARE_CONFIG", "/override/config.yaml") t.Setenv("XDG_CONFIG_HOME", "/custom/config") diff --git a/internal/config/config.go b/internal/config/config.go index c88a74d0..f6542bbe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -266,6 +266,8 @@ type Config struct { AgentsSource string `yaml:"agents_source,omitempty"` ExtrasSource string `yaml:"extras_source,omitempty"` Sources GlobalSources `yaml:"sources,omitempty"` + PluginsSource string `yaml:"plugins_source,omitempty"` + HooksSource string `yaml:"hooks_source,omitempty"` Mode string `yaml:"mode,omitempty"` // default mode: merge TargetNaming string `yaml:"target_naming,omitempty"` Targets map[string]TargetConfig `yaml:"targets"` @@ -329,6 +331,24 @@ func (c *Config) EffectiveExtrasSource() string { return ExtrasParentDir(c.EffectiveSkillsSource()) } +// EffectivePluginsSource returns the plugins source directory. +// Defaults to /plugins if not explicitly configured. +func (c *Config) EffectivePluginsSource() string { + if c.PluginsSource != "" { + return ExpandPath(c.PluginsSource) + } + return filepath.Join(BaseDir(), "plugins") +} + +// EffectiveHooksSource returns the hooks source directory. +// Defaults to /hooks if not explicitly configured. +func (c *Config) EffectiveHooksSource() string { + if c.HooksSource != "" { + return ExpandPath(c.HooksSource) + } + return filepath.Join(BaseDir(), "hooks") +} + // HasAgentTarget reports whether any configured target has an agents path, // either from the user's config agents: sub-key or from the built-in defaults. func (c *Config) HasAgentTarget() bool { @@ -516,6 +536,8 @@ func Load() (*Config, error) { cfg.Sources.Skills = expandPath(cfg.Sources.Skills) cfg.Sources.Agents = expandPath(cfg.Sources.Agents) cfg.Sources.Extras = expandPath(cfg.Sources.Extras) + cfg.PluginsSource = expandPath(cfg.PluginsSource) + cfg.HooksSource = expandPath(cfg.HooksSource) defaults := DefaultTargets() for name, target := range cfg.Targets { target.defaultTargetNaming = cfg.TargetNaming diff --git a/internal/config/plugin_hook_path.go b/internal/config/plugin_hook_path.go new file mode 100644 index 00000000..10f1fbdd --- /dev/null +++ b/internal/config/plugin_hook_path.go @@ -0,0 +1,97 @@ +package config + +import ( + "os" + "path/filepath" +) + +// PluginsSourceDirProject returns the plugin source directory in project mode. +func PluginsSourceDirProject(projectRoot string) string { + return filepath.Join(projectRoot, ".skillshare", "plugins") +} + +// HooksSourceDirProject returns the hook source directory in project mode. +func HooksSourceDirProject(projectRoot string) string { + return filepath.Join(projectRoot, ".skillshare", "hooks") +} + +// ClaudeMarketplaceRoot returns the rendered Claude marketplace root. +func ClaudeMarketplaceRoot(projectRoot string) string { + if projectRoot != "" { + return filepath.Join(projectRoot, ".skillshare", "rendered", "claude-marketplace") + } + return filepath.Join(BaseDir(), "rendered", "claude-marketplace") +} + +// CodexMarketplaceRoot returns the Codex marketplace root. +func CodexMarketplaceRoot(projectRoot string) string { + if projectRoot != "" { + return filepath.Join(projectRoot, ".agents", "plugins") + } + home, _ := osUserHomeDir() + return filepath.Join(home, ".agents", "plugins") +} + +// CodexPluginCacheBase returns the native Codex plugin cache base. +func CodexPluginCacheBase() string { + home, _ := osUserHomeDir() + return filepath.Join(home, ".codex", "plugins", "cache") +} + +// CodexPluginCacheRoot returns the Skillshare-managed Codex plugin cache root. +func CodexPluginCacheRoot() string { + return filepath.Join(CodexPluginCacheBase(), "skillshare") +} + +// CodexConfigPath returns the path to the Codex config TOML. +func CodexConfigPath() string { + home, _ := osUserHomeDir() + return filepath.Join(home, ".codex", "config.toml") +} + +// CodexHooksConfigPath returns the path to Codex hooks.json for the given scope. +func CodexHooksConfigPath(projectRoot string) string { + if projectRoot != "" { + return filepath.Join(projectRoot, ".codex", "hooks.json") + } + home, _ := osUserHomeDir() + return filepath.Join(home, ".codex", "hooks.json") +} + +// CodexHooksRoot returns the Skillshare-managed Codex hooks root for the given scope. +func CodexHooksRoot(projectRoot string) string { + if projectRoot != "" { + return filepath.Join(projectRoot, ".codex", "hooks", "skillshare") + } + home, _ := osUserHomeDir() + return filepath.Join(home, ".codex", "hooks", "skillshare") +} + +// ClaudeSettingsPath returns the Claude settings.json path for the given scope. +func ClaudeSettingsPath(projectRoot string) string { + if projectRoot != "" { + return filepath.Join(projectRoot, ".claude", "settings.json") + } + home, _ := osUserHomeDir() + return filepath.Join(home, ".claude", "settings.json") +} + +// ClaudeInstalledPluginsPath returns the Claude installed_plugins.json metadata path. +func ClaudeInstalledPluginsPath() string { + home, _ := osUserHomeDir() + return filepath.Join(home, ".claude", "plugins", "installed_plugins.json") +} + +// ClaudeHooksRoot returns the Skillshare-managed Claude hooks root for the given scope. +func ClaudeHooksRoot(projectRoot string) string { + if projectRoot != "" { + return filepath.Join(projectRoot, ".claude", "hooks", "skillshare") + } + home, _ := osUserHomeDir() + return filepath.Join(home, ".claude", "hooks", "skillshare") +} + +// osUserHomeDir exists to keep path helpers testable. +var osUserHomeDir = func() (string, error) { + return os.UserHomeDir() +} diff --git a/internal/config/plugin_hook_path_test.go b/internal/config/plugin_hook_path_test.go new file mode 100644 index 00000000..978505d0 --- /dev/null +++ b/internal/config/plugin_hook_path_test.go @@ -0,0 +1,22 @@ +package config + +import ( + "path/filepath" + "testing" +) + +func TestPluginsSourceDirProject(t *testing.T) { + got := PluginsSourceDirProject("/projects/myapp") + want := filepath.Join("/projects/myapp", ".skillshare", "plugins") + if got != want { + t.Fatalf("PluginsSourceDirProject() = %q, want %q", got, want) + } +} + +func TestHooksSourceDirProject(t *testing.T) { + got := HooksSourceDirProject("/projects/myapp") + want := filepath.Join("/projects/myapp", ".skillshare", "hooks") + if got != want { + t.Fatalf("HooksSourceDirProject() = %q, want %q", got, want) + } +} diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go new file mode 100644 index 00000000..839014e4 --- /dev/null +++ b/internal/hooks/hooks.go @@ -0,0 +1,1115 @@ +package hooks + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "gopkg.in/yaml.v3" + + "skillshare/internal/config" + "skillshare/internal/tooling" +) + +type Bundle struct { + Name string `json:"name"` + SourceDir string `json:"source_dir"` + Config HookConfig `json:"-"` + Targets map[string]int `json:"targets"` + Warnings []string `json:"warnings,omitempty"` + Issues []string `json:"issues,omitempty"` +} + +type HookConfig struct { + Name string `yaml:"name,omitempty"` + Claude *TargetSection `yaml:"claude,omitempty"` + Codex *TargetSection `yaml:"codex,omitempty"` +} + +type TargetSection struct { + Events map[string][]HookEntry `yaml:"events,omitempty"` +} + +type HookEntry struct { + Matcher any `yaml:"matcher,omitempty"` + Type string `yaml:"type,omitempty"` + Command string `yaml:"command,omitempty"` + URL string `yaml:"url,omitempty"` + Prompt string `yaml:"prompt,omitempty"` + Model string `yaml:"model,omitempty"` + If string `yaml:"if,omitempty"` + Shell string `yaml:"shell,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + AllowedEnvVars []string `yaml:"allowed_env_vars,omitempty"` + Timeout int `yaml:"timeout,omitempty"` + StatusMessage string `yaml:"status_message,omitempty"` + Async bool `yaml:"async,omitempty"` + AsyncRewake bool `yaml:"async_rewake,omitempty"` + Extra map[string]any `yaml:",inline"` +} + +type ImportOptions struct { + From string + Project string + All bool + OwnedOnly bool +} + +type SyncResult struct { + Name string `json:"name"` + Target string `json:"target"` + Root string `json:"root"` + Merged bool `json:"merged"` + Warnings []string `json:"warnings,omitempty"` +} + +type claudeMatcherGroup struct { + Matcher any + Hooks []map[string]any + Extra map[string]any +} + +type importGroup struct { + Name string + Root string + Section *TargetSection + Files map[string]string + Warnings []string +} + +type localizedImportCommand struct { + Command string + Owned bool + BundleName string + BundleRoot string + SourcePath string + TargetRel string + Warning string +} + +var ( + placeholderRE = regexp.MustCompile(`\{([A-Z0-9_]+)\}`) + + supportedCodexEvents = map[string]bool{ + "PreToolUse": true, + "PostToolUse": true, + "Notification": true, + "SessionStart": true, + "SessionEnd": true, + } + claudeToolScopedEvents = map[string]bool{ + "PreToolUse": true, + "PostToolUse": true, + "PostToolUseFailure": true, + "PermissionRequest": true, + } +) + +func Discover(sourceRoot string) ([]Bundle, error) { + entries, err := os.ReadDir(sourceRoot) + if err != nil { + if os.IsNotExist(err) { + return []Bundle{}, nil + } + return nil, err + } + var out []Bundle + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dir := filepath.Join(sourceRoot, entry.Name()) + cfg, warnings, err := readHookConfig(filepath.Join(dir, "hook.yaml")) + if err != nil { + continue + } + targets := map[string]int{} + if cfg.Claude != nil { + targets["claude"] = countEntries(cfg.Claude.Events) + } + if cfg.Codex != nil { + targets["codex"] = countEntries(cfg.Codex.Events) + } + out = append(out, Bundle{Name: entry.Name(), SourceDir: dir, Config: cfg, Targets: targets, Warnings: warnings}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +func SupportedTargets(bundle Bundle) []string { + var out []string + if supportsClaudeTarget(bundle.Config.Claude) { + out = append(out, "claude") + } + if supportsCodexTarget(bundle.Config.Codex) { + out = append(out, "codex") + } + return out +} + +func SupportsTarget(bundle Bundle, target string) bool { + for _, supported := range SupportedTargets(bundle) { + if supported == target { + return true + } + } + return false +} + +func RenderRoot(projectRoot, name, target string) string { + if target == "codex" { + return filepath.Join(config.CodexHooksRoot(projectRoot), name) + } + return filepath.Join(config.ClaudeHooksRoot(projectRoot), name) +} + +func Import(sourceRoot string, opts ImportOptions) ([]Bundle, error) { + if opts.All && opts.OwnedOnly { + return nil, fmt.Errorf("--all and --owned-only cannot be used together") + } + switch opts.From { + case "claude": + return importClaudeHooks(sourceRoot, opts) + case "codex": + return importCodexHooks(sourceRoot, opts) + default: + return nil, fmt.Errorf("hooks import requires --from claude|codex") + } +} + +func SyncAll(sourceRoot, projectRoot, target string) ([]SyncResult, error) { + bundles, err := Discover(sourceRoot) + if err != nil { + return nil, err + } + var results []SyncResult + for _, bundle := range bundles { + for _, one := range expandTargets(target) { + res, err := SyncBundle(bundle, projectRoot, one) + if err != nil { + return results, err + } + results = append(results, res) + } + } + return results, nil +} + +func SyncBundle(bundle Bundle, projectRoot, target string) (SyncResult, error) { + switch target { + case "claude": + if bundle.Config.Claude == nil { + return SyncResult{Name: bundle.Name, Target: target, Warnings: []string{"no claude hooks defined"}}, nil + } + root := RenderRoot(projectRoot, bundle.Name, target) + if err := os.MkdirAll(root, 0o755); err != nil { + return SyncResult{}, err + } + if err := copyScripts(bundle.SourceDir, root); err != nil { + return SyncResult{}, err + } + if err := mergeClaudeHooks(projectRoot, bundle.Name, root, bundle.Config.Claude); err != nil { + return SyncResult{}, err + } + return SyncResult{Name: bundle.Name, Target: target, Root: root, Merged: true, Warnings: append([]string{}, bundle.Warnings...)}, nil + case "codex": + if bundle.Config.Codex == nil { + return SyncResult{Name: bundle.Name, Target: target, Warnings: []string{"no codex hooks defined"}}, nil + } + root := RenderRoot(projectRoot, bundle.Name, target) + if err := os.MkdirAll(root, 0o755); err != nil { + return SyncResult{}, err + } + if err := copyScripts(bundle.SourceDir, root); err != nil { + return SyncResult{}, err + } + warnings, err := mergeCodexHooks(projectRoot, bundle.Name, root, bundle.Config.Codex) + if err != nil { + return SyncResult{}, err + } + if err := enableCodexHooksFeature(); err != nil { + return SyncResult{}, err + } + return SyncResult{Name: bundle.Name, Target: target, Root: root, Merged: true, Warnings: append(append([]string{}, bundle.Warnings...), warnings...)}, nil + default: + return SyncResult{}, fmt.Errorf("unsupported hook target %q", target) + } +} + +func readHookConfig(path string) (HookConfig, []string, error) { + data, err := os.ReadFile(path) + if err != nil { + return HookConfig{}, nil, err + } + var cfg HookConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return HookConfig{}, nil, err + } + var warnings []string + for _, section := range []*TargetSection{cfg.Claude, cfg.Codex} { + if section == nil { + continue + } + for event, entries := range section.Events { + if section == cfg.Codex && !supportedCodexEvents[event] { + warnings = append(warnings, fmt.Sprintf("unsupported codex event %s", event)) + } + for _, entry := range entries { + for _, match := range placeholderRE.FindAllStringSubmatch(entry.Command, -1) { + if len(match) > 1 && match[1] != "HOOK_ROOT" { + return HookConfig{}, nil, fmt.Errorf("unsupported placeholder {%s} in %s", match[1], path) + } + } + } + } + } + return cfg, warnings, nil +} + +func copyScripts(srcRoot, dstRoot string) error { + src := filepath.Join(srcRoot, "scripts") + if _, err := os.Stat(src); err != nil { + return nil + } + return tooling.ReplaceDir(src, filepath.Join(dstRoot, "scripts")) +} + +func mergeClaudeHooks(projectRoot, name, root string, target *TargetSection) error { + path := config.ClaudeSettingsPath(projectRoot) + current := map[string]any{} + if err := tooling.ReadJSON(path, ¤t); err != nil { + return err + } + managedPrefix := filepath.ToSlash(RenderRoot(projectRoot, name, "claude")) + existing := decodeClaudeHookMap(current["hooks"]) + filtered := removeManagedClaudeHandlers(existing, managedPrefix) + current["hooks"] = encodeClaudeHookMap(mergeClaudeGroups(filtered, renderClaudeHookGroups(target.Events, root))) + return tooling.WriteJSON(path, current) +} + +func mergeCodexHooks(projectRoot, name, root string, target *TargetSection) ([]string, error) { + path := config.CodexHooksConfigPath(projectRoot) + current := map[string]any{} + if err := tooling.ReadJSON(path, ¤t); err != nil { + return nil, err + } + existing := decodeHookMap(current["hooks"]) + managedPrefix := filepath.ToSlash(RenderRoot(projectRoot, name, "codex")) + replacements := map[string][]map[string]any{} + var warnings []string + for event, entries := range target.Events { + if !supportedCodexEvents[event] { + warnings = append(warnings, fmt.Sprintf("unsupported codex event %s not synced", event)) + continue + } + replacements[event] = renderHookEntries(map[string][]HookEntry{event: entries}, root)[event] + } + current["hooks"] = tooling.ManagedJSONMapMerge(existing, replacements, func(entry map[string]any) bool { + command, _ := entry["command"].(string) + return strings.Contains(filepath.ToSlash(command), managedPrefix) + }) + return warnings, tooling.WriteJSON(path, current) +} + +func renderClaudeHookGroups(events map[string][]HookEntry, root string) map[string][]claudeMatcherGroup { + out := map[string][]claudeMatcherGroup{} + for event, entries := range events { + indexByMatcher := map[string]int{} + for _, entry := range entries { + matcher := entry.Matcher + if matcher == nil { + matcher = defaultClaudeMatcher(event) + } + key := matcherSignature(matcher) + idx, ok := indexByMatcher[key] + if !ok { + idx = len(out[event]) + indexByMatcher[key] = idx + out[event] = append(out[event], claudeMatcherGroup{Matcher: cloneMatcher(matcher)}) + } + out[event][idx].Hooks = append(out[event][idx].Hooks, renderHandlerMap(entry, root)) + } + } + return out +} + +func renderHookEntries(events map[string][]HookEntry, root string) map[string][]map[string]any { + out := map[string][]map[string]any{} + for event, entries := range events { + for _, entry := range entries { + out[event] = append(out[event], renderHandlerMap(entry, root)) + } + } + return out +} + +func renderHandlerMap(entry HookEntry, root string) map[string]any { + handler := map[string]any{} + mergeAnyMap(handler, entry.Extra) + for _, key := range []string{"matcher", "hooks"} { + delete(handler, key) + } + typ := strings.TrimSpace(entry.Type) + switch { + case typ != "": + handler["type"] = typ + case entry.URL != "": + handler["type"] = "http" + case entry.Prompt != "": + handler["type"] = "prompt" + default: + handler["type"] = "command" + } + if entry.Command != "" { + handler["command"] = rewriteHookRoot(entry.Command, root) + } + if entry.URL != "" { + handler["url"] = entry.URL + } + if entry.Prompt != "" { + handler["prompt"] = entry.Prompt + } + if entry.Model != "" { + handler["model"] = entry.Model + } + if entry.If != "" { + handler["if"] = entry.If + } + if entry.Shell != "" { + handler["shell"] = entry.Shell + } + if len(entry.Headers) > 0 { + handler["headers"] = cloneStringMap(entry.Headers) + } + if len(entry.AllowedEnvVars) > 0 { + handler["allowedEnvVars"] = append([]string{}, entry.AllowedEnvVars...) + } + if entry.Timeout > 0 { + handler["timeout"] = entry.Timeout + } + if entry.StatusMessage != "" { + handler["statusMessage"] = entry.StatusMessage + } + if entry.Async { + handler["async"] = true + } + if entry.AsyncRewake { + handler["asyncRewake"] = true + } + return handler +} + +func decodeHookMap(raw any) map[string][]map[string]any { + result := map[string][]map[string]any{} + if raw == nil { + return result + } + data, err := json.Marshal(raw) + if err != nil { + return result + } + _ = json.Unmarshal(data, &result) + return result +} + +func decodeClaudeHookMap(raw any) map[string][]claudeMatcherGroup { + result := map[string][]claudeMatcherGroup{} + if raw == nil { + return result + } + payload := map[string][]map[string]any{} + data, err := json.Marshal(raw) + if err != nil { + return result + } + if err := json.Unmarshal(data, &payload); err != nil { + return result + } + for event, groups := range payload { + for _, rawGroup := range groups { + group := claudeMatcherGroup{Extra: map[string]any{}} + if hooksRaw, ok := rawGroup["hooks"]; ok { + group.Matcher = rawGroup["matcher"] + if group.Matcher == nil { + group.Matcher = defaultClaudeMatcher(event) + } + for key, value := range rawGroup { + if key == "hooks" || key == "matcher" { + continue + } + group.Extra[key] = value + } + for _, hook := range decodeJSONArrayOfMaps(hooksRaw) { + group.Hooks = append(group.Hooks, hook) + } + } else { + group.Matcher = defaultClaudeMatcher(event) + group.Hooks = append(group.Hooks, cloneAnyMap(rawGroup)) + } + if len(group.Hooks) > 0 { + result[event] = append(result[event], group) + } + } + } + return result +} + +func encodeClaudeHookMap(groups map[string][]claudeMatcherGroup) map[string][]map[string]any { + out := map[string][]map[string]any{} + events := sortedKeys(groups) + for _, event := range events { + for _, group := range groups[event] { + if len(group.Hooks) == 0 { + continue + } + rendered := map[string]any{ + "matcher": cloneMatcher(group.Matcher), + "hooks": cloneHandlerSlice(group.Hooks), + } + for key, value := range group.Extra { + rendered[key] = value + } + out[event] = append(out[event], rendered) + } + } + return out +} + +func removeManagedClaudeHandlers(groups map[string][]claudeMatcherGroup, managedPrefix string) map[string][]claudeMatcherGroup { + out := map[string][]claudeMatcherGroup{} + for event, eventGroups := range groups { + for _, group := range eventGroups { + filtered := claudeMatcherGroup{Matcher: cloneMatcher(group.Matcher), Extra: cloneAnyMap(group.Extra)} + for _, handler := range group.Hooks { + if !isManagedCommandHandler(handler, managedPrefix) { + filtered.Hooks = append(filtered.Hooks, cloneAnyMap(handler)) + } + } + if len(filtered.Hooks) > 0 { + out[event] = append(out[event], filtered) + } + } + } + return out +} + +func mergeClaudeGroups(existing, replacements map[string][]claudeMatcherGroup) map[string][]claudeMatcherGroup { + out := map[string][]claudeMatcherGroup{} + for event, groups := range existing { + for _, group := range groups { + out[event] = append(out[event], claudeMatcherGroup{ + Matcher: cloneMatcher(group.Matcher), + Hooks: cloneHandlerSlice(group.Hooks), + Extra: cloneAnyMap(group.Extra), + }) + } + } + for event, groups := range replacements { + for _, group := range groups { + matched := false + for idx := range out[event] { + if matcherSignature(out[event][idx].Matcher) != matcherSignature(group.Matcher) { + continue + } + out[event][idx].Hooks = append(out[event][idx].Hooks, cloneHandlerSlice(group.Hooks)...) + matched = true + break + } + if !matched { + out[event] = append(out[event], claudeMatcherGroup{ + Matcher: cloneMatcher(group.Matcher), + Hooks: cloneHandlerSlice(group.Hooks), + Extra: cloneAnyMap(group.Extra), + }) + } + } + } + return out +} + +func rewriteHookRoot(command, root string) string { + return strings.ReplaceAll(command, "{HOOK_ROOT}", filepath.ToSlash(root)) +} + +func countEntries(events map[string][]HookEntry) int { + total := 0 + for _, entries := range events { + total += len(entries) + } + return total +} + +func expandTargets(target string) []string { + if target == "" || target == "all" { + return []string{"claude", "codex"} + } + return []string{target} +} + +func enableCodexHooksFeature() error { + cfgPath := config.CodexConfigPath() + data, err := os.ReadFile(cfgPath) + if err != nil && !os.IsNotExist(err) { + return err + } + content := tooling.EnsureManagedTOMLBool(string(data), []string{"features"}, "codex_hooks", true) + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + return err + } + return os.WriteFile(cfgPath, []byte(content), 0o644) +} + +func importClaudeHooks(sourceRoot string, opts ImportOptions) ([]Bundle, error) { + path := config.ClaudeSettingsPath(opts.Project) + current := map[string]any{} + if err := tooling.ReadJSON(path, ¤t); err != nil { + return nil, err + } + managedRoot := config.ClaudeHooksRoot(opts.Project) + groups := groupImportedClaudeHooks(decodeClaudeHookMap(current["hooks"]), opts, managedRoot) + legacyPath := filepath.Join(filepath.Dir(path), "hooks.json") + if _, err := os.Stat(legacyPath); err == nil { + legacy := map[string]any{} + if err := tooling.ReadJSON(legacyPath, &legacy); err == nil { + for name, group := range groupImportedClaudeHooks(decodeClaudeHookMap(legacy["hooks"]), opts, managedRoot) { + groups[name] = mergeImportGroup(groups[name], group) + } + } + } + return writeImportedGroups(sourceRoot, groups, "claude") +} + +func importCodexHooks(sourceRoot string, opts ImportOptions) ([]Bundle, error) { + path := config.CodexHooksConfigPath(opts.Project) + current := map[string]any{} + if err := tooling.ReadJSON(path, ¤t); err != nil { + return nil, err + } + groups := groupImportedFlatHooks(decodeHookMap(current["hooks"]), opts, config.CodexHooksRoot(opts.Project)) + return writeImportedGroups(sourceRoot, groups, "codex") +} + +func groupImportedClaudeHooks(events map[string][]claudeMatcherGroup, opts ImportOptions, managedRoot string) map[string]importGroup { + out := map[string]importGroup{} + counter := 0 + for event, groups := range events { + for _, group := range groups { + for _, handler := range group.Hooks { + entry, name, root, copyFile, warning, owned, ok := importedHookEntryFromHandler(event, group.Matcher, handler, managedRoot) + if !ok { + continue + } + if opts.OwnedOnly && !owned { + continue + } + if name == "" { + counter++ + name = fmt.Sprintf("imported-%d", counter) + } + current := out[name] + if current.Name == "" { + current = importGroup{ + Name: name, + Root: root, + Section: &TargetSection{Events: map[string][]HookEntry{}}, + Files: map[string]string{}, + } + } + current.Section.Events[event] = append(current.Section.Events[event], entry) + if copyFile.SourcePath != "" && copyFile.TargetRel != "" { + current.Files[copyFile.SourcePath] = copyFile.TargetRel + } + if warning != "" { + current.Warnings = append(current.Warnings, warning) + } + out[name] = current + } + } + } + return out +} + +func groupImportedFlatHooks(events map[string][]map[string]any, opts ImportOptions, managedRoot string) map[string]importGroup { + out := map[string]importGroup{} + counter := 0 + for event, entries := range events { + for _, handler := range entries { + entry, name, root, copyFile, warning, owned, ok := importedHookEntryFromHandler(event, nil, handler, managedRoot) + if !ok { + continue + } + if opts.OwnedOnly && !owned { + continue + } + if name == "" { + counter++ + name = fmt.Sprintf("imported-%d", counter) + } + current := out[name] + if current.Name == "" { + current = importGroup{ + Name: name, + Root: root, + Section: &TargetSection{Events: map[string][]HookEntry{}}, + Files: map[string]string{}, + } + } + current.Section.Events[event] = append(current.Section.Events[event], entry) + if copyFile.SourcePath != "" && copyFile.TargetRel != "" { + current.Files[copyFile.SourcePath] = copyFile.TargetRel + } + if warning != "" { + current.Warnings = append(current.Warnings, warning) + } + out[name] = current + } + } + return out +} + +func importedHookEntryFromHandler(event string, matcher any, handler map[string]any, managedRoot string) (HookEntry, string, string, localizedImportCommand, string, bool, bool) { + entry := HookEntry{Extra: map[string]any{}} + for key, value := range handler { + switch key { + case "type": + entry.Type, _ = value.(string) + case "command": + entry.Command, _ = value.(string) + case "url": + entry.URL, _ = value.(string) + case "prompt": + entry.Prompt, _ = value.(string) + case "model": + entry.Model, _ = value.(string) + case "if": + entry.If, _ = value.(string) + case "shell": + entry.Shell, _ = value.(string) + case "headers": + entry.Headers = anyStringMap(value) + case "allowedEnvVars": + entry.AllowedEnvVars = anyStringSlice(value) + case "timeout": + entry.Timeout = anyInt(value) + case "statusMessage": + entry.StatusMessage, _ = value.(string) + case "async": + entry.Async, _ = value.(bool) + case "asyncRewake": + entry.AsyncRewake, _ = value.(bool) + default: + if key != "matcher" && key != "hooks" { + entry.Extra[key] = value + } + } + } + if matcher != nil { + entry.Matcher = cloneMatcher(matcher) + } + if entry.Command != "" { + localized := localizeImportedCommand(entry.Command, managedRoot) + if localized.Command != "" { + entry.Command = localized.Command + } + return entry, localized.BundleName, localized.BundleRoot, localized, localized.Warning, localized.Owned, true + } + return entry, "", "", localizedImportCommand{}, "", false, true +} + +func localizeImportedCommand(command, managedRoot string) localizedImportCommand { + command = strings.TrimSpace(command) + if command == "" { + return localizedImportCommand{} + } + if src, quote, rest, ok := parseDirectExecutableCommand(command); ok { + return buildLocalizedImportCommand(command, managedRoot, src, quote, quote, rest) + } + if prefix, src, quote, suffix, ok := parseInterpreterScriptCommand(command); ok { + return buildLocalizedImportCommand(prefix+src+suffix, managedRoot, src, prefix, quote, suffix) + } + return localizedImportCommand{ + Command: command, + Warning: fmt.Sprintf("imported hook command verbatim; could not isolate a local script path from %q", command), + } +} + +func buildLocalizedImportCommand(original, managedRoot, srcPath, prefix, quote, suffix string) localizedImportCommand { + info, err := os.Stat(srcPath) + if err != nil || info.IsDir() { + return localizedImportCommand{ + Command: original, + Warning: fmt.Sprintf("imported hook command verbatim; local script %q was not found", srcPath), + } + } + name, root, rel, owned := inferImportBundle(srcPath, managedRoot) + rewritten := prefix + quote + "{HOOK_ROOT}/scripts/" + filepath.ToSlash(rel) + quote + suffix + if prefix == quote && suffix == "" { + rewritten = quote + "{HOOK_ROOT}/scripts/" + filepath.ToSlash(rel) + quote + } + return localizedImportCommand{ + Command: rewritten, + Owned: owned, + BundleName: name, + BundleRoot: root, + SourcePath: srcPath, + TargetRel: rel, + } +} + +func parseDirectExecutableCommand(command string) (string, string, string, bool) { + if len(command) == 0 { + return "", "", "", false + } + if command[0] == '"' || command[0] == '\'' { + quote := string(command[0]) + end := strings.Index(command[1:], quote) + if end < 0 { + return "", "", "", false + } + path := command[1 : 1+end] + if filepath.IsAbs(path) { + return path, quote, command[1+end+1:], true + } + return "", "", "", false + } + if !filepath.IsAbs(strings.Fields(command)[0]) { + return "", "", "", false + } + fields := strings.Fields(command) + if len(fields) == 0 { + return "", "", "", false + } + path := fields[0] + rest := strings.TrimPrefix(command, path) + return path, "", rest, true +} + +func parseInterpreterScriptCommand(command string) (string, string, string, string, bool) { + trimmed := strings.TrimSpace(command) + fields := strings.Fields(trimmed) + if len(fields) < 2 { + return "", "", "", "", false + } + firstSpace := strings.IndexAny(trimmed, " \t") + if firstSpace < 0 { + return "", "", "", "", false + } + prefix := trimmed[:firstSpace+1] + remaining := strings.TrimLeft(trimmed[firstSpace+1:], " \t") + if remaining == "" { + return "", "", "", "", false + } + if remaining[0] == '"' || remaining[0] == '\'' { + quote := string(remaining[0]) + end := strings.Index(remaining[1:], quote) + if end < 0 { + return "", "", "", "", false + } + path := remaining[1 : 1+end] + if !filepath.IsAbs(path) { + return "", "", "", "", false + } + return prefix, path, quote, remaining[1+end+1:], true + } + second := strings.Fields(remaining) + if len(second) == 0 || !filepath.IsAbs(second[0]) { + return "", "", "", "", false + } + path := second[0] + return prefix, path, "", strings.TrimPrefix(remaining, path), true +} + +func inferImportBundle(srcPath, managedRoot string) (string, string, string, bool) { + scriptPath := filepath.Clean(srcPath) + managedRoot = filepath.Clean(managedRoot) + if managedRoot != "" { + prefix := managedRoot + string(filepath.Separator) + if strings.HasPrefix(scriptPath, prefix) { + relToManaged, _ := filepath.Rel(managedRoot, scriptPath) + parts := strings.Split(filepath.ToSlash(relToManaged), "/") + if len(parts) >= 2 { + bundleName := parts[0] + root := filepath.Join(managedRoot, bundleName) + if parts[1] == "scripts" { + return bundleName, root, filepath.ToSlash(filepath.Join(parts[2:]...)), true + } + return bundleName, root, filepath.Base(scriptPath), true + } + } + } + dir := filepath.Dir(scriptPath) + name := filepath.Base(dir) + root := dir + if filepath.Base(filepath.Dir(scriptPath)) == "scripts" { + root = filepath.Dir(filepath.Dir(scriptPath)) + name = filepath.Base(root) + rel, _ := filepath.Rel(filepath.Join(root, "scripts"), scriptPath) + return name, root, filepath.ToSlash(rel), false + } + if name == "hooks" || name == ".claude" || name == ".codex" || name == "" || name == "." { + base := strings.TrimSuffix(filepath.Base(scriptPath), filepath.Ext(scriptPath)) + if base != "" { + name = base + } + } + return name, root, filepath.Base(scriptPath), false +} + +func mergeImportGroup(dst, src importGroup) importGroup { + if dst.Name == "" { + return src + } + for event, entries := range src.Section.Events { + dst.Section.Events[event] = append(dst.Section.Events[event], entries...) + } + if dst.Files == nil { + dst.Files = map[string]string{} + } + for srcPath, rel := range src.Files { + dst.Files[srcPath] = rel + } + dst.Warnings = append(dst.Warnings, src.Warnings...) + return dst +} + +func writeImportedGroups(sourceRoot string, groups map[string]importGroup, target string) ([]Bundle, error) { + if err := os.MkdirAll(sourceRoot, 0o755); err != nil { + return nil, err + } + names := make([]string, 0, len(groups)) + for name := range groups { + names = append(names, name) + } + sort.Strings(names) + var bundles []Bundle + for _, name := range names { + group := groups[name] + dir := filepath.Join(sourceRoot, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + cfg := HookConfig{Name: name} + switch target { + case "claude": + cfg.Claude = group.Section + case "codex": + cfg.Codex = group.Section + } + data, err := yaml.Marshal(&cfg) + if err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(dir, "hook.yaml"), data, 0o644); err != nil { + return nil, err + } + if err := copyImportedHookFiles(dir, group.Files); err != nil { + return nil, err + } + discovered, ok, err := discoverImportedBundle(dir, name) + if err != nil { + return nil, err + } + if ok { + discovered.Warnings = append(discovered.Warnings, group.Warnings...) + bundles = append(bundles, discovered) + } + } + return bundles, nil +} + +func copyImportedHookFiles(dir string, files map[string]string) error { + for srcPath, rel := range files { + data, err := os.ReadFile(srcPath) + if err != nil { + return err + } + dst := filepath.Join(dir, "scripts", filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + info, err := os.Stat(srcPath) + if err != nil { + return err + } + if err := os.WriteFile(dst, data, info.Mode()); err != nil { + return err + } + } + return nil +} + +func discoverImportedBundle(dir, name string) (Bundle, bool, error) { + cfg, warnings, err := readHookConfig(filepath.Join(dir, "hook.yaml")) + if err != nil { + return Bundle{}, false, err + } + targets := map[string]int{} + if cfg.Claude != nil { + targets["claude"] = countEntries(cfg.Claude.Events) + } + if cfg.Codex != nil { + targets["codex"] = countEntries(cfg.Codex.Events) + } + return Bundle{Name: name, SourceDir: dir, Config: cfg, Targets: targets, Warnings: warnings}, true, nil +} + +func supportsClaudeTarget(section *TargetSection) bool { + return section != nil && countEntries(section.Events) > 0 +} + +func supportsCodexTarget(section *TargetSection) bool { + if section == nil { + return false + } + for event, entries := range section.Events { + if supportedCodexEvents[event] && len(entries) > 0 { + return true + } + } + return false +} + +func defaultClaudeMatcher(event string) any { + if claudeToolScopedEvents[event] { + return "*" + } + return "" +} + +func matcherSignature(matcher any) string { + data, err := json.Marshal(cloneMatcher(matcher)) + if err != nil { + return fmt.Sprintf("%v", matcher) + } + return string(data) +} + +func cloneMatcher(matcher any) any { + data, err := json.Marshal(matcher) + if err != nil { + return matcher + } + var out any + if err := json.Unmarshal(data, &out); err != nil { + return matcher + } + return out +} + +func cloneAnyMap(src map[string]any) map[string]any { + if len(src) == 0 { + return nil + } + dst := make(map[string]any, len(src)) + for key, value := range src { + dst[key] = value + } + return dst +} + +func cloneStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + dst := make(map[string]string, len(src)) + for key, value := range src { + dst[key] = value + } + return dst +} + +func mergeAnyMap(dst map[string]any, src map[string]any) { + for key, value := range src { + dst[key] = value + } +} + +func decodeJSONArrayOfMaps(raw any) []map[string]any { + var items []map[string]any + data, err := json.Marshal(raw) + if err != nil { + return nil + } + _ = json.Unmarshal(data, &items) + return items +} + +func cloneHandlerSlice(src []map[string]any) []map[string]any { + if len(src) == 0 { + return nil + } + dst := make([]map[string]any, 0, len(src)) + for _, item := range src { + dst = append(dst, cloneAnyMap(item)) + } + return dst +} + +func isManagedCommandHandler(handler map[string]any, managedPrefix string) bool { + command, _ := handler["command"].(string) + return command != "" && strings.Contains(filepath.ToSlash(command), managedPrefix) +} + +func anyStringMap(raw any) map[string]string { + items, ok := raw.(map[string]any) + if !ok { + return nil + } + out := make(map[string]string, len(items)) + for key, value := range items { + if str, ok := value.(string); ok { + out[key] = str + } + } + return out +} + +func anyStringSlice(raw any) []string { + list, ok := raw.([]any) + if !ok { + if typed, ok := raw.([]string); ok { + return append([]string{}, typed...) + } + return nil + } + out := make([]string, 0, len(list)) + for _, value := range list { + if str, ok := value.(string); ok { + out = append(out, str) + } + } + return out +} + +func anyInt(raw any) int { + switch value := raw.(type) { + case float64: + return int(value) + case int: + return value + case int64: + return int(value) + case json.Number: + i, _ := value.Int64() + return int(i) + case string: + i, _ := strconv.Atoi(value) + return i + default: + return 0 + } +} + +func sortedKeys[V any](items map[string]V) []string { + keys := make([]string, 0, len(items)) + for key := range items { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go new file mode 100644 index 00000000..b370663d --- /dev/null +++ b/internal/hooks/hooks_test.go @@ -0,0 +1,383 @@ +package hooks + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "skillshare/internal/config" +) + +func TestSyncBundleCodexMergesHooksAndEnablesFeature(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + source := filepath.Join(t.TempDir(), "hooks", "audit") + writeHookBundle(t, source, ` +codex: + events: + PreToolUse: + - command: "{HOOK_ROOT}/scripts/pre.sh" +`) + writeFile(t, filepath.Join(source, "scripts", "pre.sh"), "#!/bin/sh\nexit 0\n") + writeFile(t, config.CodexHooksConfigPath(""), `{"hooks":{"PreToolUse":[{"command":"echo unmanaged"}]}}`) + + bundle := mustBundle(t, source, "audit") + res, err := SyncBundle(bundle, "", "codex") + if err != nil { + t.Fatalf("SyncBundle() error = %v", err) + } + if len(res.Warnings) != 0 { + t.Fatalf("unexpected warnings: %v", res.Warnings) + } + + hooksData, err := os.ReadFile(config.CodexHooksConfigPath("")) + if err != nil { + t.Fatalf("read hooks.json: %v", err) + } + text := string(hooksData) + if !strings.Contains(text, "echo unmanaged") { + t.Fatalf("expected unmanaged hook to remain:\n%s", text) + } + if !strings.Contains(text, filepath.ToSlash(filepath.Join(config.CodexHooksRoot(""), "audit", "scripts", "pre.sh"))) { + t.Fatalf("expected managed hook path in hooks.json:\n%s", text) + } + cfgData, err := os.ReadFile(config.CodexConfigPath()) + if err != nil { + t.Fatalf("read codex config: %v", err) + } + if !strings.Contains(string(cfgData), "[features]") || !strings.Contains(string(cfgData), "codex_hooks = true") { + t.Fatalf("expected codex_hooks feature enabled:\n%s", string(cfgData)) + } +} + +func TestImportClaudeHooksCreatesBundle(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + sourceRoot := filepath.Join(t.TempDir(), "hooks") + + hookRoot := filepath.Join(home, ".claude", "hooks", "skillshare", "audit") + writeFile(t, filepath.Join(hookRoot, "scripts", "pre.sh"), "#!/bin/sh\n") + writeFile(t, config.ClaudeSettingsPath(""), `{"hooks":{"PreToolUse":[{"command":"`+filepath.ToSlash(filepath.Join(hookRoot, "scripts", "pre.sh"))+`"}]}}`) + + bundles, err := Import(sourceRoot, ImportOptions{From: "claude", OwnedOnly: true}) + if err != nil { + t.Fatalf("Import() error = %v", err) + } + if len(bundles) != 1 { + t.Fatalf("expected 1 imported bundle, got %d", len(bundles)) + } + data, err := os.ReadFile(filepath.Join(sourceRoot, "audit", "hook.yaml")) + if err != nil { + t.Fatalf("read imported hook.yaml: %v", err) + } + if !strings.Contains(string(data), "{HOOK_ROOT}/scripts/pre.sh") { + t.Fatalf("expected HOOK_ROOT rewrite in hook.yaml:\n%s", string(data)) + } +} + +func TestSyncBundleCodexWarnsOnUnsupportedEvents(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + source := filepath.Join(t.TempDir(), "hooks", "audit") + writeHookBundle(t, source, ` +codex: + events: + UnsupportedEvent: + - command: "{HOOK_ROOT}/scripts/pre.sh" +`) + writeFile(t, filepath.Join(source, "scripts", "pre.sh"), "#!/bin/sh\nexit 0\n") + + bundle := mustBundle(t, source, "audit") + res, err := SyncBundle(bundle, "", "codex") + if err != nil { + t.Fatalf("SyncBundle() error = %v", err) + } + if len(res.Warnings) == 0 { + t.Fatalf("expected unsupported event warning") + } +} + +func TestSyncBundleClaudePreservesMatcherGroupsAndMetadata(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + source := filepath.Join(t.TempDir(), "hooks", "audit") + writeHookBundle(t, source, ` +claude: + events: + PreToolUse: + - matcher: Bash + command: "{HOOK_ROOT}/scripts/pre.sh" + timeout: 8000 + status_message: "Working..." + if: "Bash(git *)" + SessionStart: + - command: "{HOOK_ROOT}/scripts/start.sh" +`) + writeFile(t, filepath.Join(source, "scripts", "pre.sh"), "#!/bin/sh\nexit 0\n") + writeFile(t, filepath.Join(source, "scripts", "start.sh"), "#!/bin/sh\nexit 0\n") + + managedRoot := filepath.Join(config.ClaudeHooksRoot(""), "audit") + settings := map[string]any{ + "hooks": map[string]any{ + "PreToolUse": []any{ + map[string]any{ + "matcher": "Bash", + "hooks": []any{ + map[string]any{"type": "command", "command": "echo unmanaged", "timeout": 1}, + map[string]any{"type": "command", "command": filepath.ToSlash(filepath.Join(managedRoot, "scripts", "old.sh")), "statusMessage": "old"}, + }, + }, + map[string]any{ + "matcher": map[string]any{"tool_name": "Edit"}, + "hooks": []any{ + map[string]any{"type": "command", "command": "echo edit"}, + }, + }, + }, + "SessionStart": []any{ + map[string]any{ + "hooks": []any{ + map[string]any{"type": "command", "command": "echo existing"}, + }, + }, + }, + }, + } + data, _ := json.Marshal(settings) + writeFile(t, config.ClaudeSettingsPath(""), string(data)) + + bundle := mustBundle(t, source, "audit") + if _, err := SyncBundle(bundle, "", "claude"); err != nil { + t.Fatalf("SyncBundle() error = %v", err) + } + + var synced struct { + Hooks map[string][]map[string]any `json:"hooks"` + } + readJSONFile(t, config.ClaudeSettingsPath(""), &synced) + + preGroups := synced.Hooks["PreToolUse"] + if len(preGroups) != 2 { + t.Fatalf("expected 2 PreToolUse groups, got %#v", preGroups) + } + bashHooks := findClaudeGroupHooks(t, preGroups, "Bash") + if len(bashHooks) != 2 { + t.Fatalf("expected unmanaged + managed Bash hooks, got %#v", bashHooks) + } + if command, _ := bashHooks[1]["command"].(string); !strings.Contains(command, "/audit/scripts/pre.sh") { + t.Fatalf("expected rewritten managed hook, got %#v", bashHooks[1]) + } + if bashHooks[1]["statusMessage"] != "Working..." || int(bashHooks[1]["timeout"].(float64)) != 8000 { + t.Fatalf("expected metadata preserved, got %#v", bashHooks[1]) + } + if bashHooks[1]["if"] != "Bash(git *)" { + t.Fatalf("expected if condition preserved, got %#v", bashHooks[1]) + } + if strings.Contains(string(readRawFile(t, config.ClaudeSettingsPath(""))), "old.sh") { + t.Fatalf("expected managed old hook to be removed") + } + + startGroups := synced.Hooks["SessionStart"] + if len(startGroups) != 1 { + t.Fatalf("expected 1 SessionStart group, got %#v", startGroups) + } + if startGroups[0]["matcher"] != "" { + t.Fatalf("expected non-tool default matcher to normalize to empty string, got %#v", startGroups[0]["matcher"]) + } +} + +func TestImportClaudeHooksMergesLegacyAndLocalizesCommands(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + sourceRoot := filepath.Join(t.TempDir(), "hooks") + + gitnexusScript := filepath.Join(home, ".claude", "hooks", "gitnexus", "gitnexus-hook.cjs") + rewriteScript := filepath.Join(home, ".claude", "hooks", "rtk-rewrite.sh") + legacyScript := filepath.Join(home, ".claude", "legacy", "legacy-hook.cjs") + writeFile(t, gitnexusScript, "console.log('gitnexus')\n") + writeFile(t, rewriteScript, "#!/bin/sh\nexit 0\n") + writeFile(t, legacyScript, "console.log('legacy')\n") + + writeFile(t, config.ClaudeSettingsPath(""), `{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"`+filepath.ToSlash(gitnexusScript)+`\"", + "timeout": 8000, + "statusMessage": "Enriching..." + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "`+filepath.ToSlash(rewriteScript)+`" + } + ] + } + ] + } +}`) + writeFile(t, filepath.Join(home, ".claude", "hooks.json"), `{ + "hooks": { + "PreToolUse": [ + { + "matcher": {"tool_name": "Grep|Glob|Bash"}, + "hooks": [ + { + "type": "command", + "command": "node \"`+filepath.ToSlash(legacyScript)+`\"" + } + ] + } + ] + } +}`) + + bundles, err := Import(sourceRoot, ImportOptions{From: "claude", All: true}) + if err != nil { + t.Fatalf("Import() error = %v", err) + } + if len(bundles) != 3 { + t.Fatalf("expected 3 imported bundles, got %d", len(bundles)) + } + + gitnexusHook := readRawFile(t, filepath.Join(sourceRoot, "gitnexus", "hook.yaml")) + if !strings.Contains(string(gitnexusHook), `matcher: Bash`) || !strings.Contains(string(gitnexusHook), `status_message: Enriching...`) { + t.Fatalf("expected gitnexus metadata and matcher preserved:\n%s", gitnexusHook) + } + if !strings.Contains(string(gitnexusHook), `node "{HOOK_ROOT}/scripts/gitnexus-hook.cjs"`) { + t.Fatalf("expected localized node command, got:\n%s", gitnexusHook) + } + if _, err := os.Stat(filepath.Join(sourceRoot, "gitnexus", "scripts", "gitnexus-hook.cjs")); err != nil { + t.Fatalf("expected localized gitnexus script copy: %v", err) + } + + rewriteHook := readRawFile(t, filepath.Join(sourceRoot, "rtk-rewrite", "hook.yaml")) + if !strings.Contains(string(rewriteHook), `command: '{HOOK_ROOT}/scripts/rtk-rewrite.sh'`) && + !strings.Contains(string(rewriteHook), `command: "{HOOK_ROOT}/scripts/rtk-rewrite.sh"`) { + t.Fatalf("expected localized direct script command, got:\n%s", rewriteHook) + } + + legacyHook := readRawFile(t, filepath.Join(sourceRoot, "legacy", "hook.yaml")) + if !strings.Contains(string(legacyHook), `tool_name: Grep|Glob|Bash`) { + t.Fatalf("expected legacy matcher object preserved, got:\n%s", legacyHook) + } +} + +func TestImportClaudeHooksOwnedOnlyAndConflict(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + sourceRoot := filepath.Join(t.TempDir(), "hooks") + + managedScript := filepath.Join(config.ClaudeHooksRoot(""), "audit", "scripts", "pre.sh") + unmanagedScript := filepath.Join(home, ".claude", "hooks", "gitnexus", "gitnexus-hook.cjs") + writeFile(t, managedScript, "#!/bin/sh\nexit 0\n") + writeFile(t, unmanagedScript, "console.log('gitnexus')\n") + writeFile(t, config.ClaudeSettingsPath(""), `{ + "hooks": { + "PreToolUse": [ + {"hooks": [{"type":"command","command":"`+filepath.ToSlash(managedScript)+`"}]}, + {"hooks": [{"type":"command","command":"node \"`+filepath.ToSlash(unmanagedScript)+`\""}]} + ] + } +}`) + + bundles, err := Import(sourceRoot, ImportOptions{From: "claude", OwnedOnly: true}) + if err != nil { + t.Fatalf("Import() owned-only error = %v", err) + } + if len(bundles) != 1 || bundles[0].Name != "audit" { + t.Fatalf("expected only managed audit bundle, got %+v", bundles) + } + if _, err := Import(sourceRoot, ImportOptions{From: "claude", All: true, OwnedOnly: true}); err == nil { + t.Fatalf("expected --all and --owned-only conflict") + } +} + +func writeHookBundle(t *testing.T, root, yamlText string) { + t.Helper() + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatalf("mkdir root: %v", err) + } + writeFile(t, filepath.Join(root, "hook.yaml"), yamlText) +} + +func mustBundle(t *testing.T, source, name string) Bundle { + t.Helper() + cfg, warnings, err := readHookConfig(filepath.Join(source, "hook.yaml")) + if err != nil { + t.Fatalf("readHookConfig: %v", err) + } + targets := map[string]int{} + if cfg.Claude != nil { + targets["claude"] = countEntries(cfg.Claude.Events) + } + if cfg.Codex != nil { + targets["codex"] = countEntries(cfg.Codex.Events) + } + return Bundle{Name: name, SourceDir: source, Config: cfg, Targets: targets, Warnings: warnings} +} + +func writeFile(t *testing.T, path, contents 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(contents), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func readJSONFile(t *testing.T, path string, dst any) { + t.Helper() + data := readRawFile(t, path) + if err := json.Unmarshal(data, dst); err != nil { + t.Fatalf("unmarshal %s: %v\n%s", path, err, data) + } +} + +func readRawFile(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return data +} + +func findClaudeGroupHooks(t *testing.T, groups []map[string]any, matcher string) []map[string]any { + t.Helper() + for _, group := range groups { + if group["matcher"] != matcher { + continue + } + rawHooks, ok := group["hooks"].([]any) + if !ok { + t.Fatalf("hooks field has unexpected type: %#v", group["hooks"]) + } + var out []map[string]any + for _, item := range rawHooks { + hook, ok := item.(map[string]any) + if !ok { + t.Fatalf("hook item has unexpected type: %#v", item) + } + out = append(out, hook) + } + return out + } + t.Fatalf("matcher %q not found in %#v", matcher, groups) + return nil +} diff --git a/internal/install/sparse_checkout.go b/internal/install/sparse_checkout.go index 2764d564..24689aee 100644 --- a/internal/install/sparse_checkout.go +++ b/internal/install/sparse_checkout.go @@ -2,7 +2,9 @@ package install import ( "fmt" + "os" "os/exec" + "path/filepath" "strconv" "strings" ) @@ -70,6 +72,9 @@ func sparseCloneSubdir(url, subdir, destPath, branch string, extraEnv []string, if err := runGitCommandWithProgress([]string{"checkout"}, destPath, extraEnv, nil); err != nil { return err } + if _, err := os.Stat(filepath.Join(destPath, filepath.FromSlash(subdir))); err != nil { + return fmt.Errorf("sparse checkout path %q not found: %w", subdir, err) + } return nil } diff --git a/internal/plugins/plugins.go b/internal/plugins/plugins.go new file mode 100644 index 00000000..c1d2b377 --- /dev/null +++ b/internal/plugins/plugins.go @@ -0,0 +1,729 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "skillshare/internal/config" + "skillshare/internal/tooling" +) + +type Bundle struct { + Name string `json:"name"` + SourceDir string `json:"source_dir"` + HasClaude bool `json:"has_claude"` + HasCodex bool `json:"has_codex"` + GeneratedTargets []string `json:"generated_targets,omitempty"` + RenderedTargets []string `json:"rendered_targets,omitempty"` + InstalledTargets []string `json:"installed_targets,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Issues []string `json:"issues,omitempty"` +} + +type ImportOptions struct { + From string +} + +type SyncResult struct { + Name string `json:"name"` + Target string `json:"target"` + Rendered string `json:"rendered"` + Installed bool `json:"installed"` + Generated bool `json:"generated,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +type translationMeta struct { + Shared map[string]any `yaml:"shared,omitempty"` +} + +func Discover(sourceRoot string) ([]Bundle, error) { + entries, err := os.ReadDir(sourceRoot) + if err != nil { + if os.IsNotExist(err) { + return []Bundle{}, nil + } + return nil, err + } + var out []Bundle + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dir := filepath.Join(sourceRoot, entry.Name()) + bundle, ok, err := discoverBundle(dir, entry.Name()) + if err != nil { + return nil, err + } + if !ok { + continue + } + out = append(out, bundle) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +func discoverBundle(dir, fallbackName string) (Bundle, bool, error) { + claudeManifest := filepath.Join(dir, ".claude-plugin", "plugin.json") + codexManifest := filepath.Join(dir, ".codex-plugin", "plugin.json") + metaPath := filepath.Join(dir, "skillshare.plugin.yaml") + bundle := Bundle{ + Name: fallbackName, + SourceDir: dir, + HasClaude: fileExists(claudeManifest), + HasCodex: fileExists(codexManifest), + } + if !bundle.HasClaude && !bundle.HasCodex && !fileExists(metaPath) { + return Bundle{}, false, nil + } + if bundle.HasClaude { + if name, err := manifestName(claudeManifest); err == nil && name != "" { + bundle.Name = name + } + } + if bundle.Name == fallbackName && bundle.HasCodex { + if name, err := manifestName(codexManifest); err == nil && name != "" { + bundle.Name = name + } + } + if _, warnings, _, err := loadSharedMetadata(dir, bundle.Name); err == nil { + bundle.Warnings = append(bundle.Warnings, warnings...) + } + bundle.GeneratedTargets = GeneratedTargets(bundle) + return bundle, true, nil +} + +func SupportedTargets(bundle Bundle) []string { + var out []string + if bundle.HasClaude || canGenerateTarget(bundle, "claude") { + out = append(out, "claude") + } + if bundle.HasCodex || canGenerateTarget(bundle, "codex") { + out = append(out, "codex") + } + return out +} + +func GeneratedTargets(bundle Bundle) []string { + var out []string + if !bundle.HasClaude && canGenerateTarget(bundle, "claude") { + out = append(out, "claude") + } + if !bundle.HasCodex && canGenerateTarget(bundle, "codex") { + out = append(out, "codex") + } + return out +} + +func SupportsTarget(bundle Bundle, target string) bool { + for _, supported := range SupportedTargets(bundle) { + if supported == target { + return true + } + } + return false +} + +func RenderRoot(projectRoot, name, target string) string { + if target == "codex" { + return filepath.Join(config.CodexMarketplaceRoot(projectRoot), name) + } + return filepath.Join(config.ClaudeMarketplaceRoot(projectRoot), "plugins", name) +} + +func Import(sourceRoot, ref string, opts ImportOptions) (Bundle, error) { + resolved, err := resolveImportSource(ref, opts.From) + if err != nil { + return Bundle{}, err + } + name, err := bundleNameFromSource(resolved, ref) + if err != nil { + return Bundle{}, err + } + dst := filepath.Join(sourceRoot, name) + if err := os.MkdirAll(sourceRoot, 0o755); err != nil { + return Bundle{}, err + } + if err := tooling.MergeDir(resolved, dst); err != nil { + return Bundle{}, err + } + bundle, ok, err := discoverBundle(dst, name) + if err != nil { + return Bundle{}, err + } + if !ok { + return Bundle{}, fmt.Errorf("imported plugin %q is missing native manifests and translation metadata", name) + } + return bundle, nil +} + +func SyncAll(sourceRoot, projectRoot, target string, install bool) ([]SyncResult, error) { + bundles, err := Discover(sourceRoot) + if err != nil { + return nil, err + } + var results []SyncResult + for _, bundle := range bundles { + for _, one := range expandTargets(target) { + res, err := SyncBundle(bundle, projectRoot, one, install) + if err != nil { + return results, err + } + results = append(results, res) + } + } + return results, nil +} + +func SyncBundle(bundle Bundle, projectRoot, target string, install bool) (SyncResult, error) { + if !SupportsTarget(bundle, target) { + return SyncResult{}, fmt.Errorf("plugin %q cannot sync to %s", bundle.Name, target) + } + stagedDir, generated, warnings, err := stageBundle(bundle, target) + if err != nil { + return SyncResult{}, err + } + defer os.RemoveAll(stagedDir) + + switch target { + case "claude": + renderRoot := config.ClaudeMarketplaceRoot(projectRoot) + renderedDir := RenderRoot(projectRoot, bundle.Name, target) + if err := tooling.ReplaceDir(stagedDir, renderedDir); err != nil { + return SyncResult{}, err + } + if err := writeClaudeMarketplace(renderRoot); err != nil { + return SyncResult{}, err + } + if install { + scope := "user" + if projectRoot != "" { + scope = "project" + } + if err := runClaudePluginFlow(bundle.Name, renderRoot, scope); err != nil { + return SyncResult{}, err + } + } + return SyncResult{Name: bundle.Name, Target: target, Rendered: renderedDir, Installed: install, Generated: generated, Warnings: warnings}, nil + case "codex": + renderRoot := config.CodexMarketplaceRoot(projectRoot) + renderedDir := RenderRoot(projectRoot, bundle.Name, target) + if err := tooling.ReplaceDir(stagedDir, renderedDir); err != nil { + return SyncResult{}, err + } + if err := writeCodexMarketplace(renderRoot); err != nil { + return SyncResult{}, err + } + if install { + cacheDir := filepath.Join(config.CodexPluginCacheRoot(), bundle.Name, "local") + if err := tooling.ReplaceDir(stagedDir, cacheDir); err != nil { + return SyncResult{}, err + } + if err := enableCodexPlugin(bundle.Name); err != nil { + return SyncResult{}, err + } + } + return SyncResult{Name: bundle.Name, Target: target, Rendered: renderedDir, Installed: install, Generated: generated, Warnings: warnings}, nil + default: + return SyncResult{}, fmt.Errorf("unsupported plugin target %q", target) + } +} + +func stageBundle(bundle Bundle, target string) (string, bool, []string, error) { + tempDir, err := os.MkdirTemp("", "skillshare-plugin-*") + if err != nil { + return "", false, nil, err + } + generated := false + if err := copySharedFiles(bundle.SourceDir, tempDir); err != nil { + os.RemoveAll(tempDir) + return "", false, nil, err + } + switch target { + case "claude": + if bundle.HasClaude { + if err := tooling.MergeDir(filepath.Join(bundle.SourceDir, ".claude-plugin"), filepath.Join(tempDir, ".claude-plugin")); err != nil { + os.RemoveAll(tempDir) + return "", false, nil, err + } + } else { + warnings, err := generateManifest(tempDir, bundle.SourceDir, bundle.Name, target) + if err != nil { + os.RemoveAll(tempDir) + return "", false, nil, err + } + generated = true + return tempDir, generated, append([]string{"generated .claude-plugin/plugin.json"}, warnings...), nil + } + case "codex": + if bundle.HasCodex { + if err := tooling.MergeDir(filepath.Join(bundle.SourceDir, ".codex-plugin"), filepath.Join(tempDir, ".codex-plugin")); err != nil { + os.RemoveAll(tempDir) + return "", false, nil, err + } + } else { + warnings, err := generateManifest(tempDir, bundle.SourceDir, bundle.Name, target) + if err != nil { + os.RemoveAll(tempDir) + return "", false, nil, err + } + generated = true + return tempDir, generated, append([]string{"generated .codex-plugin/plugin.json"}, warnings...), nil + } + default: + os.RemoveAll(tempDir) + return "", false, nil, fmt.Errorf("unsupported plugin target %q", target) + } + return tempDir, generated, nil, nil +} + +func copySharedFiles(srcRoot, dstRoot string) error { + entries, err := os.ReadDir(srcRoot) + if err != nil { + return err + } + for _, entry := range entries { + name := entry.Name() + if name == ".claude-plugin" || name == ".codex-plugin" || name == "skillshare.plugin.yaml" { + continue + } + src := filepath.Join(srcRoot, name) + dst := filepath.Join(dstRoot, name) + if entry.IsDir() { + if err := tooling.MergeDir(src, dst); err != nil { + return err + } + continue + } + data, err := os.ReadFile(src) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + if err := os.WriteFile(dst, data, 0o644); err != nil { + return err + } + } + return nil +} + +func generateManifest(bundleDir, sourceDir, name, target string) ([]string, error) { + meta, warnings, ok, err := loadSharedMetadata(sourceDir, name) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("plugin %q cannot be synced to %s: no shared metadata available", name, target) + } + switch target { + case "claude": + if err := tooling.WriteJSON(filepath.Join(bundleDir, ".claude-plugin", "plugin.json"), meta); err != nil { + return nil, err + } + case "codex": + if err := tooling.WriteJSON(filepath.Join(bundleDir, ".codex-plugin", "plugin.json"), meta); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unsupported plugin target %q", target) + } + return warnings, nil +} + +func loadSharedMetadata(bundleDir, name string) (map[string]any, []string, bool, error) { + var sourceManifest string + switch { + case fileExists(filepath.Join(bundleDir, ".claude-plugin", "plugin.json")): + sourceManifest = filepath.Join(bundleDir, ".claude-plugin", "plugin.json") + case fileExists(filepath.Join(bundleDir, ".codex-plugin", "plugin.json")): + sourceManifest = filepath.Join(bundleDir, ".codex-plugin", "plugin.json") + } + meta := map[string]any{ + "name": name, + "version": "0.1.0", + } + if sourceManifest != "" { + data, err := os.ReadFile(sourceManifest) + if err != nil { + return nil, nil, false, err + } + if err := json.Unmarshal(data, &meta); err != nil { + return nil, nil, false, err + } + } + if fileExists(filepath.Join(bundleDir, "skillshare.plugin.yaml")) { + var sidecar translationMeta + data, err := os.ReadFile(filepath.Join(bundleDir, "skillshare.plugin.yaml")) + if err != nil { + return nil, nil, false, err + } + if err := yaml.Unmarshal(data, &sidecar); err != nil { + return nil, nil, false, err + } + for k, v := range sidecar.Shared { + meta[k] = v + } + } + ok := sourceManifest != "" || fileExists(filepath.Join(bundleDir, "skillshare.plugin.yaml")) + if !ok { + for _, sharedDir := range []string{"skills", "assets", "vendor"} { + if dirExists(filepath.Join(bundleDir, sharedDir)) { + ok = true + break + } + } + } + var warnings []string + for _, skipped := range []string{"commands", "agents", "hooks", ".lsp.json", "monitors", "bin", ".app.json"} { + path := filepath.Join(bundleDir, skipped) + if fileExists(path) || dirExists(path) { + warnings = append(warnings, fmt.Sprintf("skipped %s during cross-target translation", skipped)) + } + } + return meta, warnings, ok, nil +} + +func writeClaudeMarketplace(root string) error { + pluginsDir := filepath.Join(root, "plugins") + entries, err := os.ReadDir(pluginsDir) + if err != nil { + return err + } + type pluginEntry struct { + Name string `json:"name"` + Ref string `json:"ref"` + } + var plugins []pluginEntry + for _, entry := range entries { + if entry.IsDir() { + plugins = append(plugins, pluginEntry{Name: entry.Name(), Ref: entry.Name() + "@skillshare"}) + } + } + sort.Slice(plugins, func(i, j int) bool { return plugins[i].Name < plugins[j].Name }) + return tooling.WriteJSON(filepath.Join(root, ".claude-plugin", "marketplace.json"), map[string]any{"plugins": plugins}) +} + +func writeCodexMarketplace(root string) error { + entries, err := os.ReadDir(root) + if err != nil { + return err + } + type pluginEntry struct { + Name string `json:"name"` + Ref string `json:"ref"` + } + var plugins []pluginEntry + for _, entry := range entries { + if entry.IsDir() { + plugins = append(plugins, pluginEntry{Name: entry.Name(), Ref: entry.Name() + "@skillshare"}) + } + } + sort.Slice(plugins, func(i, j int) bool { return plugins[i].Name < plugins[j].Name }) + return tooling.WriteJSON(filepath.Join(root, "marketplace.json"), map[string]any{"plugins": plugins}) +} + +func runClaudePluginFlow(name, renderRoot, scope string) error { + verb := "install" + if isClaudePluginInstalled(name + "@skillshare") { + verb = "update" + } + cmds := [][]string{ + {"plugin", "marketplace", "add", renderRoot, "--scope", scope}, + {"plugin", verb, name + "@skillshare", "--scope", scope}, + {"plugin", "enable", name + "@skillshare"}, + } + for _, args := range cmds { + cmd := exec.Command("claude", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("claude %s failed: %w", strings.Join(args, " "), err) + } + } + return nil +} + +func isClaudePluginInstalled(ref string) bool { + installed, _ := readClaudeInstalledPlugins() + if _, ok := installed[ref]; ok { + return true + } + name := pluginRefName(ref) + home, _ := os.UserHomeDir() + for _, candidate := range []string{ + filepath.Join(home, ".claude", "plugins", name), + filepath.Join(home, ".claude", "plugins", ref), + } { + if _, err := os.Stat(candidate); err == nil { + return true + } + } + return false +} + +func enableCodexPlugin(name string) error { + cfgPath := config.CodexConfigPath() + data, err := os.ReadFile(cfgPath) + if err != nil && !os.IsNotExist(err) { + return err + } + content := string(data) + content = tooling.EnsureManagedTOMLBool(content, []string{"plugins", fmt.Sprintf("%q", name+"@skillshare")}, "enabled", true) + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + return err + } + return os.WriteFile(cfgPath, []byte(content), 0o644) +} + +func resolveImportSource(ref, from string) (string, error) { + if info, err := os.Stat(ref); err == nil && info.IsDir() { + return ref, nil + } + switch from { + case "claude": + return resolveClaudeImportPath(ref) + case "codex": + return resolveCodexImportPath(ref) + default: + return "", fmt.Errorf("plugin import requires --from claude|codex when %q is not a local directory", ref) + } +} + +func resolveClaudeImportPath(ref string) (string, error) { + installed, _ := readClaudeInstalledPlugins() + resolvedRef := ref + if len(installed) > 0 { + var err error + resolvedRef, err = resolvePluginRef(ref, installed, "claude") + if err != nil { + return "", err + } + } + if records, ok := installed[resolvedRef]; ok { + for _, record := range records { + if info, err := os.Stat(record.InstallPath); err == nil && info.IsDir() { + return record.InstallPath, nil + } + } + } + name := pluginRefName(resolvedRef) + home, _ := os.UserHomeDir() + for _, candidate := range []string{ + filepath.Join(home, ".claude", "plugins", name), + filepath.Join(home, ".claude", "plugins", resolvedRef), + } { + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate, nil + } + } + return "", fmt.Errorf("claude plugin %q not found in local state", ref) +} + +func resolveCodexImportPath(ref string) (string, error) { + installed, _ := discoverCodexInstalledPlugins() + resolvedRef := ref + if len(installed) > 0 { + var err error + resolvedRef, err = resolvePluginRef(ref, installed, "codex") + if err != nil { + return "", err + } + } + if candidates, ok := installed[resolvedRef]; ok { + for _, candidate := range candidates { + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate, nil + } + } + } + name := pluginRefName(resolvedRef) + for _, candidate := range []string{ + filepath.Join(config.CodexMarketplaceRoot(""), name), + filepath.Join(config.CodexMarketplaceRoot(""), "plugins", name), + } { + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate, nil + } + } + return "", fmt.Errorf("codex plugin %q not found in local state", ref) +} + +func bundleNameFromSource(sourceDir, ref string) (string, error) { + for _, manifest := range []string{ + filepath.Join(sourceDir, ".claude-plugin", "plugin.json"), + filepath.Join(sourceDir, ".codex-plugin", "plugin.json"), + } { + if fileExists(manifest) { + name, err := manifestName(manifest) + if err != nil { + return "", err + } + if name != "" { + return name, nil + } + } + } + return pluginRefName(filepath.Base(ref)), nil +} + +func manifestName(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + return "", err + } + if name, _ := payload["name"].(string); name != "" { + return name, nil + } + return "", nil +} + +func pluginRefName(ref string) string { + base := filepath.Base(ref) + if idx := strings.Index(base, "@"); idx >= 0 { + return base[:idx] + } + return base +} + +func expandTargets(target string) []string { + if target == "" || target == "all" { + return []string{"claude", "codex"} + } + return []string{target} +} + +type claudeInstalledPluginRecord struct { + InstallPath string `json:"installPath"` +} + +func canGenerateTarget(bundle Bundle, target string) bool { + _, _, ok, err := loadSharedMetadata(bundle.SourceDir, bundle.Name) + return err == nil && ok +} + +func readClaudeInstalledPlugins() (map[string][]claudeInstalledPluginRecord, error) { + var payload struct { + Plugins map[string][]claudeInstalledPluginRecord `json:"plugins"` + } + if err := tooling.ReadJSON(config.ClaudeInstalledPluginsPath(), &payload); err != nil { + return nil, err + } + if payload.Plugins == nil { + return map[string][]claudeInstalledPluginRecord{}, nil + } + return payload.Plugins, nil +} + +func resolvePluginRef[T any](ref string, installed map[string][]T, ecosystem string) (string, error) { + if ref == "" { + return "", fmt.Errorf("%s plugin reference cannot be empty", ecosystem) + } + if strings.Contains(ref, "@") { + if _, ok := installed[ref]; ok { + return ref, nil + } + return ref, fmt.Errorf("%s plugin %q not found in local state", ecosystem, ref) + } + var matches []string + for fullRef := range installed { + if pluginRefName(fullRef) == ref { + matches = append(matches, fullRef) + } + } + sort.Strings(matches) + switch len(matches) { + case 0: + return "", fmt.Errorf("%s plugin %q not found in local state", ecosystem, ref) + case 1: + return matches[0], nil + default: + return "", fmt.Errorf("%s plugin %q is ambiguous; use full ref", ecosystem, ref) + } +} + +func discoverCodexInstalledPlugins() (map[string][]string, error) { + refs := readCodexConfiguredPluginRefs() + cacheBase := config.CodexPluginCacheBase() + pattern := filepath.Join(cacheBase, "*", "*", "*") + matches, _ := filepath.Glob(pattern) + out := map[string][]string{} + for _, dir := range matches { + if !dirExists(dir) || !fileExists(filepath.Join(dir, ".codex-plugin", "plugin.json")) { + continue + } + provider := filepath.Base(filepath.Dir(filepath.Dir(dir))) + name := filepath.Base(filepath.Dir(dir)) + if filepath.Base(dir) == "local" { + name = filepath.Base(filepath.Dir(dir)) + provider = "skillshare" + } + ref := name + "@" + provider + out[ref] = append(out[ref], dir) + } + for _, ref := range refs { + if _, ok := out[ref]; ok { + continue + } + name, provider := splitPluginRef(ref) + pattern := filepath.Join(cacheBase, provider, name, "*") + candidates, _ := filepath.Glob(pattern) + for _, candidate := range candidates { + if dirExists(candidate) && fileExists(filepath.Join(candidate, ".codex-plugin", "plugin.json")) { + out[ref] = append(out[ref], candidate) + } + } + } + return out, nil +} + +func readCodexConfiguredPluginRefs() []string { + data, err := os.ReadFile(config.CodexConfigPath()) + if err != nil { + return nil + } + re := regexp.MustCompile(`(?m)^\[plugins\.(?:"([^"]+)"|'([^']+)')\]`) + var refs []string + for _, match := range re.FindAllStringSubmatch(string(data), -1) { + ref := match[1] + if ref == "" { + ref = match[2] + } + if ref != "" { + refs = append(refs, ref) + } + } + return refs +} + +func splitPluginRef(ref string) (string, string) { + name := pluginRefName(ref) + if idx := strings.Index(ref, "@"); idx >= 0 { + return name, ref[idx+1:] + } + return name, "" +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/internal/plugins/plugins_test.go b/internal/plugins/plugins_test.go new file mode 100644 index 00000000..8ccbeef5 --- /dev/null +++ b/internal/plugins/plugins_test.go @@ -0,0 +1,219 @@ +package plugins + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "skillshare/internal/config" +) + +func TestSyncBundleCodexInstallsMarketplaceCacheAndConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + source := filepath.Join(t.TempDir(), "plugins", "demo") + mkdirAll(t, filepath.Join(source, ".codex-plugin")) + writeFile(t, filepath.Join(source, ".codex-plugin", "plugin.json"), `{"name":"demo","version":"1.0.0"}`) + + bundle := Bundle{Name: "demo", SourceDir: source, HasCodex: true} + res, err := SyncBundle(bundle, "", "codex", true) + if err != nil { + t.Fatalf("SyncBundle() error = %v", err) + } + if !res.Installed { + t.Fatalf("expected installed result") + } + + marketplacePlugin := filepath.Join(home, ".agents", "plugins", "demo", ".codex-plugin", "plugin.json") + if _, err := os.Stat(marketplacePlugin); err != nil { + t.Fatalf("marketplace plugin missing: %v", err) + } + cachePlugin := filepath.Join(home, ".codex", "plugins", "cache", "skillshare", "demo", "local", ".codex-plugin", "plugin.json") + if _, err := os.Stat(cachePlugin); err != nil { + t.Fatalf("cache plugin missing: %v", err) + } + cfgData, err := os.ReadFile(config.CodexConfigPath()) + if err != nil { + t.Fatalf("read codex config: %v", err) + } + if !strings.Contains(string(cfgData), `[plugins."demo@skillshare"]`) || !strings.Contains(string(cfgData), `enabled = true`) { + t.Fatalf("codex config missing plugin enablement:\n%s", string(cfgData)) + } +} + +func TestSyncBundleClaudeRendersAndInvokesCLI(t *testing.T) { + home := t.TempDir() + binDir := filepath.Join(t.TempDir(), "bin") + t.Setenv("HOME", home) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + source := filepath.Join(t.TempDir(), "plugins", "demo") + mkdirAll(t, filepath.Join(source, ".claude-plugin")) + writeFile(t, filepath.Join(source, ".claude-plugin", "plugin.json"), `{"name":"demo","version":"1.0.0"}`) + + logPath := filepath.Join(t.TempDir(), "claude.log") + mkdirAll(t, binDir) + writeFile(t, filepath.Join(binDir, "claude"), "#!/bin/sh\necho \"$@\" >> "+logPath+"\n") + if err := os.Chmod(filepath.Join(binDir, "claude"), 0o755); err != nil { + t.Fatalf("chmod claude stub: %v", err) + } + + bundle := Bundle{Name: "demo", SourceDir: source, HasClaude: true} + if _, err := SyncBundle(bundle, "", "claude", true); err != nil { + t.Fatalf("SyncBundle() error = %v", err) + } + + rendered := filepath.Join(config.ClaudeMarketplaceRoot(""), "plugins", "demo", ".claude-plugin", "plugin.json") + if _, err := os.Stat(rendered); err != nil { + t.Fatalf("rendered plugin missing: %v", err) + } + logData, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read claude log: %v", err) + } + logText := string(logData) + for _, fragment := range []string{"plugin marketplace add", "plugin install demo@skillshare", "plugin enable demo@skillshare"} { + if !strings.Contains(logText, fragment) { + t.Fatalf("expected claude invocation containing %q, got:\n%s", fragment, logText) + } + } +} + +func TestImportMergesExistingBundleAcrossEcosystems(t *testing.T) { + home := t.TempDir() + sourceRoot := filepath.Join(t.TempDir(), "plugins") + t.Setenv("HOME", home) + + claudePlugin := filepath.Join(home, ".claude", "plugins", "demo") + writeFile(t, filepath.Join(claudePlugin, ".claude-plugin", "plugin.json"), `{"name":"demo","version":"1.0.0"}`) + writeFile(t, filepath.Join(claudePlugin, "README.md"), "claude") + if _, err := Import(sourceRoot, "demo", ImportOptions{From: "claude"}); err != nil { + t.Fatalf("claude Import() error = %v", err) + } + + codexPlugin := filepath.Join(home, ".agents", "plugins", "demo") + writeFile(t, filepath.Join(codexPlugin, ".codex-plugin", "plugin.json"), `{"name":"demo","version":"1.0.0"}`) + writeFile(t, filepath.Join(codexPlugin, "vendor.txt"), "codex") + bundle, err := Import(sourceRoot, "demo", ImportOptions{From: "codex"}) + if err != nil { + t.Fatalf("codex Import() error = %v", err) + } + if !bundle.HasClaude || !bundle.HasCodex { + t.Fatalf("expected merged bundle to have both manifests: %+v", bundle) + } + if _, err := os.Stat(filepath.Join(sourceRoot, "demo", "README.md")); err != nil { + t.Fatalf("expected claude file preserved: %v", err) + } + if _, err := os.Stat(filepath.Join(sourceRoot, "demo", "vendor.txt")); err != nil { + t.Fatalf("expected codex file merged: %v", err) + } +} + +func TestImportClaudeUsesInstalledPluginsMetadataAndDetectsAmbiguity(t *testing.T) { + home := t.TempDir() + sourceRoot := filepath.Join(t.TempDir(), "plugins") + t.Setenv("HOME", home) + + alpha := filepath.Join(home, ".claude", "plugins", "cache", "alpha", "demo", "1.0.0") + beta := filepath.Join(home, ".claude", "plugins", "cache", "beta", "demo", "2.0.0") + writeFile(t, filepath.Join(alpha, ".claude-plugin", "plugin.json"), `{"name":"demo","version":"1.0.0"}`) + writeFile(t, filepath.Join(beta, ".claude-plugin", "plugin.json"), `{"name":"demo","version":"2.0.0"}`) + writeFile(t, config.ClaudeInstalledPluginsPath(), `{ + "plugins": { + "demo@alpha": [{"installPath": "`+filepath.ToSlash(alpha)+`"}], + "demo@beta": [{"installPath": "`+filepath.ToSlash(beta)+`"}] + } +}`) + + if _, err := Import(sourceRoot, "demo", ImportOptions{From: "claude"}); err == nil || !strings.Contains(err.Error(), "use full ref") { + t.Fatalf("expected ambiguity error, got %v", err) + } + bundle, err := Import(sourceRoot, "demo@alpha", ImportOptions{From: "claude"}) + if err != nil { + t.Fatalf("Import() full ref error = %v", err) + } + if bundle.Name != "demo" || !bundle.HasClaude { + t.Fatalf("unexpected bundle: %+v", bundle) + } +} + +func TestResolveCodexImportPathUsesHashedCacheAndConfiguredRefs(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + writeFile(t, config.CodexConfigPath(), ` +[plugins."demo@provider-a"] +enabled = true + +[plugins."demo@provider-b"] +enabled = false +`) + providerA := filepath.Join(config.CodexPluginCacheBase(), "provider-a", "demo", "hash-a") + providerB := filepath.Join(config.CodexPluginCacheBase(), "provider-b", "demo", "hash-b") + writeFile(t, filepath.Join(providerA, ".codex-plugin", "plugin.json"), `{"name":"demo","version":"1.0.0"}`) + writeFile(t, filepath.Join(providerB, ".codex-plugin", "plugin.json"), `{"name":"demo","version":"2.0.0"}`) + + if _, err := resolveCodexImportPath("demo"); err == nil || !strings.Contains(err.Error(), "use full ref") { + t.Fatalf("expected ambiguity error, got %v", err) + } + resolved, err := resolveCodexImportPath("demo@provider-a") + if err != nil { + t.Fatalf("resolveCodexImportPath() error = %v", err) + } + if resolved != providerA { + t.Fatalf("resolved path = %q, want %q", resolved, providerA) + } +} + +func TestSyncBundleClaudeUsesInstalledMetadataForUpdate(t *testing.T) { + home := t.TempDir() + binDir := filepath.Join(t.TempDir(), "bin") + t.Setenv("HOME", home) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + source := filepath.Join(t.TempDir(), "plugins", "demo") + mkdirAll(t, filepath.Join(source, ".claude-plugin")) + writeFile(t, filepath.Join(source, ".claude-plugin", "plugin.json"), `{"name":"demo","version":"1.0.0"}`) + writeFile(t, config.ClaudeInstalledPluginsPath(), `{ + "plugins": { + "demo@skillshare": [{"installPath": "/tmp/demo"}] + } +}`) + + logPath := filepath.Join(t.TempDir(), "claude.log") + mkdirAll(t, binDir) + writeFile(t, filepath.Join(binDir, "claude"), "#!/bin/sh\necho \"$@\" >> "+logPath+"\n") + if err := os.Chmod(filepath.Join(binDir, "claude"), 0o755); err != nil { + t.Fatalf("chmod claude stub: %v", err) + } + + bundle := Bundle{Name: "demo", SourceDir: source, HasClaude: true} + if _, err := SyncBundle(bundle, "", "claude", true); err != nil { + t.Fatalf("SyncBundle() error = %v", err) + } + + logData, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read claude log: %v", err) + } + if !strings.Contains(string(logData), "plugin update demo@skillshare") { + t.Fatalf("expected update flow, got:\n%s", string(logData)) + } +} + +func mkdirAll(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } +} + +func writeFile(t *testing.T, path, contents string) { + t.Helper() + mkdirAll(t, filepath.Dir(path)) + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/internal/server/handler_plugin_hook.go b/internal/server/handler_plugin_hook.go new file mode 100644 index 00000000..cc8846e6 --- /dev/null +++ b/internal/server/handler_plugin_hook.go @@ -0,0 +1,160 @@ +package server + +import ( + "encoding/json" + "net/http" + "os" + + "skillshare/internal/config" + hookpkg "skillshare/internal/hooks" + pluginpkg "skillshare/internal/plugins" +) + +func (s *Server) pluginSourceDir() string { + if s.IsProjectMode() { + return config.PluginsSourceDirProject(s.projectRoot) + } + return s.cfg.EffectivePluginsSource() +} + +func (s *Server) hookSourceDir() string { + if s.IsProjectMode() { + return config.HooksSourceDirProject(s.projectRoot) + } + return s.cfg.EffectiveHooksSource() +} + +func (s *Server) handlePlugins(w http.ResponseWriter, r *http.Request) { + bundles, err := pluginpkg.Discover(s.pluginSourceDir()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, map[string]any{"plugins": bundles}) +} + +func (s *Server) handlePluginsDiff(w http.ResponseWriter, r *http.Request) { + type pluginDiffEntry struct { + Name string `json:"name"` + Target string `json:"target"` + Synced bool `json:"synced"` + Items []string `json:"items,omitempty"` + } + bundles, _ := pluginpkg.Discover(s.pluginSourceDir()) + var out []pluginDiffEntry + for _, bundle := range bundles { + for _, target := range pluginpkg.SupportedTargets(bundle) { + rendered := pluginpkg.RenderRoot(s.projectRoot, bundle.Name, target) + _, err := os.Stat(rendered) + entry := pluginDiffEntry{Name: bundle.Name, Target: target, Synced: err == nil} + if err != nil { + entry.Items = []string{"missing rendered state: " + rendered} + } + out = append(out, entry) + } + } + writeJSON(w, map[string]any{"plugins": out}) +} + +func (s *Server) handlePluginsImport(w http.ResponseWriter, r *http.Request) { + var body struct { + Ref string `json:"ref"` + From string `json:"from"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + bundle, err := pluginpkg.Import(s.pluginSourceDir(), body.Ref, pluginpkg.ImportOptions{From: body.From}) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, map[string]any{"plugin": bundle}) +} + +func (s *Server) handlePluginsSync(w http.ResponseWriter, r *http.Request) { + var body struct { + Target string `json:"target"` + Install *bool `json:"install"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + install := true + if body.Install != nil { + install = *body.Install + } + results, err := pluginpkg.SyncAll(s.pluginSourceDir(), s.projectRoot, body.Target, install) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, map[string]any{"plugins": results}) +} + +func (s *Server) handleHooks(w http.ResponseWriter, r *http.Request) { + bundles, err := hookpkg.Discover(s.hookSourceDir()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, map[string]any{"hooks": bundles}) +} + +func (s *Server) handleHooksDiff(w http.ResponseWriter, r *http.Request) { + type hookDiffEntry struct { + Name string `json:"name"` + Target string `json:"target"` + Synced bool `json:"synced"` + Items []string `json:"items,omitempty"` + } + bundles, _ := hookpkg.Discover(s.hookSourceDir()) + var out []hookDiffEntry + for _, bundle := range bundles { + for _, target := range hookpkg.SupportedTargets(bundle) { + root := hookpkg.RenderRoot(s.projectRoot, bundle.Name, target) + _, err := os.Stat(root) + entry := hookDiffEntry{Name: bundle.Name, Target: target, Synced: err == nil} + if err != nil { + entry.Items = []string{"missing rendered state: " + root} + } + out = append(out, entry) + } + } + writeJSON(w, map[string]any{"hooks": out}) +} + +func (s *Server) handleHooksImport(w http.ResponseWriter, r *http.Request) { + var body struct { + From string `json:"from"` + All bool `json:"all"` + OwnedOnly bool `json:"owned_only"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + bundles, err := hookpkg.Import(s.hookSourceDir(), hookpkg.ImportOptions{ + From: body.From, + Project: s.projectRoot, + All: body.All, + OwnedOnly: body.OwnedOnly, + }) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, map[string]any{"hooks": bundles}) +} + +func (s *Server) handleHooksSync(w http.ResponseWriter, r *http.Request) { + var body struct { + Target string `json:"target"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + results, err := hookpkg.SyncAll(s.hookSourceDir(), s.projectRoot, body.Target) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, map[string]any{"hooks": results}) +} diff --git a/internal/server/handler_plugin_hook_test.go b/internal/server/handler_plugin_hook_test.go new file mode 100644 index 00000000..503c98b0 --- /dev/null +++ b/internal/server/handler_plugin_hook_test.go @@ -0,0 +1,127 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestHandlePluginsAndHooks(t *testing.T) { + s, _ := newTestServer(t) + + pluginRoot := filepath.Join(s.pluginSourceDir(), "demo") + if err := os.MkdirAll(filepath.Join(pluginRoot, ".claude-plugin"), 0o755); err != nil { + t.Fatalf("mkdir plugin: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginRoot, ".claude-plugin", "plugin.json"), []byte(`{"name":"demo"}`), 0o644); err != nil { + t.Fatalf("write plugin manifest: %v", err) + } + + hookRoot := filepath.Join(s.hookSourceDir(), "audit") + if err := os.MkdirAll(hookRoot, 0o755); err != nil { + t.Fatalf("mkdir hook: %v", err) + } + if err := os.WriteFile(filepath.Join(hookRoot, "hook.yaml"), []byte("claude:\n events:\n PreToolUse:\n - command: \"{HOOK_ROOT}/scripts/pre.sh\"\n"), 0o644); err != nil { + t.Fatalf("write hook.yaml: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/plugins", nil) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("plugins status = %d body=%s", rr.Code, rr.Body.String()) + } + var pluginsResp struct { + Plugins []map[string]any `json:"plugins"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &pluginsResp); err != nil { + t.Fatalf("unmarshal plugins: %v", err) + } + if len(pluginsResp.Plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(pluginsResp.Plugins)) + } + + req = httptest.NewRequest(http.MethodGet, "/api/hooks", nil) + rr = httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("hooks status = %d body=%s", rr.Code, rr.Body.String()) + } + var hooksResp struct { + Hooks []map[string]any `json:"hooks"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &hooksResp); err != nil { + t.Fatalf("unmarshal hooks: %v", err) + } + if len(hooksResp.Hooks) != 1 { + t.Fatalf("expected 1 hook, got %d", len(hooksResp.Hooks)) + } +} + +func TestHandlePluginAndHookDiffOnlyIncludesSupportedTargets(t *testing.T) { + s, _ := newTestServer(t) + + pluginRoot := filepath.Join(s.pluginSourceDir(), "claude-only") + if err := os.MkdirAll(filepath.Join(pluginRoot, ".claude-plugin"), 0o755); err != nil { + t.Fatalf("mkdir plugin: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginRoot, ".claude-plugin", "plugin.json"), []byte(`{"name":"claude-only"}`), 0o644); err != nil { + t.Fatalf("write plugin manifest: %v", err) + } + + hookRoot := filepath.Join(s.hookSourceDir(), "claude-only") + if err := os.MkdirAll(hookRoot, 0o755); err != nil { + t.Fatalf("mkdir hook: %v", err) + } + if err := os.WriteFile(filepath.Join(hookRoot, "hook.yaml"), []byte("claude:\n events:\n SessionStart:\n - command: \"{HOOK_ROOT}/scripts/start.sh\"\n"), 0o644); err != nil { + t.Fatalf("write hook.yaml: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/plugins/diff", nil) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("plugins diff status = %d body=%s", rr.Code, rr.Body.String()) + } + var pluginsResp struct { + Plugins []struct { + Name string `json:"name"` + Target string `json:"target"` + } `json:"plugins"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &pluginsResp); err != nil { + t.Fatalf("unmarshal plugins diff: %v", err) + } + if len(pluginsResp.Plugins) != 2 { + t.Fatalf("expected generated plugin targets, got %+v", pluginsResp.Plugins) + } + targets := map[string]bool{} + for _, entry := range pluginsResp.Plugins { + targets[entry.Target] = true + } + if !targets["claude"] || !targets["codex"] { + t.Fatalf("expected claude and codex plugin diff entries, got %+v", pluginsResp.Plugins) + } + + req = httptest.NewRequest(http.MethodGet, "/api/hooks/diff", nil) + rr = httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("hooks diff status = %d body=%s", rr.Code, rr.Body.String()) + } + var hooksResp struct { + Hooks []struct { + Name string `json:"name"` + Target string `json:"target"` + } `json:"hooks"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &hooksResp); err != nil { + t.Fatalf("unmarshal hooks diff: %v", err) + } + if len(hooksResp.Hooks) != 1 || hooksResp.Hooks[0].Target != "claude" { + t.Fatalf("expected only claude hook diff entry, got %+v", hooksResp.Hooks) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 6c03bd62..e249f3b9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -456,6 +456,16 @@ func (s *Server) registerRoutes() { s.mux.HandleFunc("PATCH /api/extras/{name}/mode", s.handleExtrasMode) s.mux.HandleFunc("DELETE /api/extras/{name}", s.handleExtrasDelete) + // Plugins & Hooks + s.mux.HandleFunc("GET /api/plugins", s.handlePlugins) + s.mux.HandleFunc("GET /api/plugins/diff", s.handlePluginsDiff) + s.mux.HandleFunc("POST /api/plugins/import", s.handlePluginsImport) + s.mux.HandleFunc("POST /api/plugins/sync", s.handlePluginsSync) + s.mux.HandleFunc("GET /api/hooks", s.handleHooks) + s.mux.HandleFunc("GET /api/hooks/diff", s.handleHooksDiff) + s.mux.HandleFunc("POST /api/hooks/import", s.handleHooksImport) + s.mux.HandleFunc("POST /api/hooks/sync", s.handleHooksSync) + // Git s.mux.HandleFunc("GET /api/git/status", s.handleGitStatus) s.mux.HandleFunc("GET /api/git/branches", s.handleGitBranches) diff --git a/internal/tooling/fs.go b/internal/tooling/fs.go new file mode 100644 index 00000000..f7f853eb --- /dev/null +++ b/internal/tooling/fs.go @@ -0,0 +1,217 @@ +package tooling + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +// CopyDir recursively copies src into dst, skipping .git directories. +func CopyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if info.IsDir() { + if info.Name() == ".git" { + return filepath.SkipDir + } + return os.MkdirAll(target, info.Mode()) + } + return copyFile(path, target, info.Mode()) + }) +} + +func copyFile(src, dst string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} + +// ReplaceDir atomically replaces dst with a fresh copy of src. +func ReplaceDir(src, dst string) error { + if err := os.RemoveAll(dst); err != nil { + return err + } + return CopyDir(src, dst) +} + +// MergeDir recursively copies src into dst without removing dst first. +// When a file/dir type conflicts, the destination path is replaced. +func MergeDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return os.MkdirAll(dst, 0o755) + } + target := filepath.Join(dst, rel) + if info.IsDir() { + if info.Name() == ".git" { + return filepath.SkipDir + } + if existing, statErr := os.Stat(target); statErr == nil && !existing.IsDir() { + if err := os.Remove(target); err != nil { + return err + } + } + return os.MkdirAll(target, info.Mode()) + } + if existing, statErr := os.Stat(target); statErr == nil && existing.IsDir() { + if err := os.RemoveAll(target); err != nil { + return err + } + } + return copyFile(path, target, info.Mode()) + }) +} + +// WriteJSON writes pretty JSON to path, creating parent directories. +func WriteJSON(path string, v any) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0644) +} + +// ReadJSON reads JSON from path into dst. Missing files are not errors. +func ReadJSON(path string, dst any) error { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if len(strings.TrimSpace(string(data))) == 0 { + return nil + } + return json.Unmarshal(data, dst) +} + +// EnsureManagedTableEntry adds or updates a simple TOML boolean key in a table. +func EnsureManagedTableEntry(content, header, key string, value bool) string { + lines := strings.Split(content, "\n") + sectionLine := "[" + header + "]" + valueLine := fmt.Sprintf("%s = %t", key, value) + for i := 0; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) != sectionLine { + continue + } + for j := i + 1; j < len(lines); j++ { + trimmed := strings.TrimSpace(lines[j]) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + lines = append(lines[:j], append([]string{valueLine}, lines[j:]...)...) + return strings.Join(lines, "\n") + } + if strings.HasPrefix(trimmed, key+" =") { + lines[j] = valueLine + return strings.Join(lines, "\n") + } + } + lines = append(lines, valueLine) + return strings.Join(lines, "\n") + } + if strings.TrimSpace(content) != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + if content != "" { + content += "\n" + } + return content + sectionLine + "\n" + valueLine + "\n" +} + +// EnsureManagedTOMLBool adds or updates a boolean key in a TOML table path. +// The implementation is line-oriented but table-aware, so unrelated content is preserved. +func EnsureManagedTOMLBool(content string, tablePath []string, key string, value bool) string { + sectionLine := "[" + strings.Join(tablePath, ".") + "]" + valueLine := fmt.Sprintf("%s = %t", key, value) + lines := strings.Split(content, "\n") + for i := 0; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) != sectionLine { + continue + } + insertAt := len(lines) + for j := i + 1; j < len(lines); j++ { + trimmed := strings.TrimSpace(lines[j]) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + insertAt = j + break + } + if strings.HasPrefix(trimmed, key+" =") { + lines[j] = valueLine + return strings.Join(lines, "\n") + } + } + lines = append(lines[:insertAt], append([]string{valueLine}, lines[insertAt:]...)...) + return strings.Join(lines, "\n") + } + if strings.TrimSpace(content) != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + if strings.TrimSpace(content) != "" { + content += "\n" + } + return content + sectionLine + "\n" + valueLine + "\n" +} + +// ManagedJSONMapMerge rewrites a top-level object key containing event arrays. +// Unmanaged entries are kept, managed entries are dropped when shouldRemove returns true, +// and replacement entries are appended in sorted key order for stable output. +func ManagedJSONMapMerge(current map[string][]map[string]any, replacements map[string][]map[string]any, shouldRemove func(map[string]any) bool) map[string][]map[string]any { + out := make(map[string][]map[string]any, len(current)+len(replacements)) + for event, entries := range current { + var kept []map[string]any + for _, entry := range entries { + if shouldRemove != nil && shouldRemove(entry) { + continue + } + kept = append(kept, entry) + } + if len(kept) > 0 { + out[event] = kept + } + } + keys := make([]string, 0, len(replacements)) + for event := range replacements { + keys = append(keys, event) + } + sort.Strings(keys) + for _, event := range keys { + out[event] = append(out[event], replacements[event]...) + } + return out +} diff --git a/internal/tooling/sandbox_prepare_state_test.go b/internal/tooling/sandbox_prepare_state_test.go new file mode 100644 index 00000000..fe763fa1 --- /dev/null +++ b/internal/tooling/sandbox_prepare_state_test.go @@ -0,0 +1,96 @@ +package tooling + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestPrepareSandboxHostStateCopiesOnlyReferencedHookScripts(t *testing.T) { + hostHome := t.TempDir() + sandboxHome := t.TempDir() + + quotedScript := filepath.Join(hostHome, ".claude", "hooks", "quoted", "gitnexus-hook.cjs") + directScript := filepath.Join(hostHome, ".claude", "hooks", "direct", "rewrite.sh") + unrelatedFile := filepath.Join(hostHome, ".claude", "notes", "ignore-me.txt") + + writeSandboxTestFile(t, quotedScript, "console.log('quoted')\n") + writeSandboxTestFile(t, directScript, "#!/bin/sh\nexit 0\n") + writeSandboxTestFile(t, unrelatedFile, "ignore\n") + writeSandboxTestFile(t, filepath.Join(hostHome, ".claude", "settings.json"), `{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + {"type": "command", "command": "node \"`+filepath.ToSlash(quotedScript)+`\""} + ] + } + ] + }, + "notes_path": "`+filepath.ToSlash(unrelatedFile)+`" +}`) + writeSandboxTestFile(t, filepath.Join(hostHome, ".claude", "hooks.json"), `{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + {"type": "command", "command": "`+filepath.ToSlash(directScript)+`"} + ] + } + ] + } +}`) + writeSandboxTestFile(t, filepath.Join(hostHome, ".codex", "config.toml"), "") + writeSandboxTestFile(t, filepath.Join(hostHome, ".codex", "hooks.json"), `{"hooks":{}}`) + + _, testFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(testFile), "..", "..")) + scriptPath := filepath.Join(repoRoot, "scripts", "prepare_sandbox_host_state.sh") + + cmd := exec.Command("bash", scriptPath, hostHome, sandboxHome) + cmd.Dir = repoRoot + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("prepare_sandbox_host_state.sh failed: %v\n%s", err, output) + } + + for _, path := range []string{ + filepath.Join(sandboxHome, ".claude", "hooks", "quoted", "gitnexus-hook.cjs"), + filepath.Join(sandboxHome, ".claude", "hooks", "direct", "rewrite.sh"), + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected copied hook script %s: %v", path, err) + } + } + if _, err := os.Stat(filepath.Join(sandboxHome, ".claude", "notes", "ignore-me.txt")); !os.IsNotExist(err) { + t.Fatalf("expected unrelated file to stay uncopied, err=%v", err) + } + + settingsData, err := os.ReadFile(filepath.Join(sandboxHome, ".claude", "settings.json")) + if err != nil { + t.Fatalf("read sandbox settings: %v", err) + } + settingsText := string(settingsData) + if strings.Contains(settingsText, filepath.ToSlash(hostHome)) { + t.Fatalf("expected sandbox settings to rewrite host home:\n%s", settingsText) + } + if !strings.Contains(settingsText, filepath.ToSlash(sandboxHome)) { + t.Fatalf("expected sandbox settings to contain sandbox home:\n%s", settingsText) + } +} + +func writeSandboxTestFile(t *testing.T, path, contents 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(contents), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/schemas/config.schema.json b/schemas/config.schema.json index 2724e5ae..c620ce7e 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -40,6 +40,18 @@ } } }, + "plugins_source": { + "type": "string", + "description": "Path to the plugin bundle source directory. Supports ~ for home directory. Defaults to ~/.config/skillshare/plugins if not set.", + "default": "~/.config/skillshare/plugins", + "examples": ["~/.config/skillshare/plugins", "/home/user/my-plugins"] + }, + "hooks_source": { + "type": "string", + "description": "Path to the standalone hook bundle source directory. Supports ~ for home directory. Defaults to ~/.config/skillshare/hooks if not set.", + "default": "~/.config/skillshare/hooks", + "examples": ["~/.config/skillshare/hooks", "/home/user/my-hooks"] + }, "mode": { "type": "string", "description": "Default sync mode for all targets.", diff --git a/scripts/prepare_sandbox_host_state.sh b/scripts/prepare_sandbox_host_state.sh new file mode 100755 index 00000000..983b076f --- /dev/null +++ b/scripts/prepare_sandbox_host_state.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# Copy minimal Claude/Codex state into a sandbox home without bulk caches/marketplaces. +set -euo pipefail + +HOST_HOME="${1:-$HOME}" +SANDBOX_HOME="${2:-/sandbox-home}" + +copy_file() { + local src="$1" + local dst="$2" + if [[ ! -f "$src" ]]; then + return 0 + fi + mkdir -p "$(dirname "$dst")" + cp "$src" "$dst" +} + +copy_dir() { + local src="$1" + local dst="$2" + if [[ ! -d "$src" ]]; then + return 0 + fi + mkdir -p "$(dirname "$dst")" + rm -rf "$dst" + cp -R "$src" "$dst" +} + +rewrite_home_prefix() { + local file="$1" + if [[ -f "$file" ]]; then + HOST_HOME="$HOST_HOME" SANDBOX_HOME="$SANDBOX_HOME" perl -0pi -e 's/\Q$ENV{HOST_HOME}\E/$ENV{SANDBOX_HOME}/g' "$file" + fi +} + +extract_home_paths() { + local file="$1" + if [[ ! -f "$file" ]]; then + return 0 + fi + HOST_HOME="$HOST_HOME" perl -MJSON::PP -e ' + use strict; + use warnings; + + my ($home, $path) = @ARGV; + open my $fh, "<", $path or exit 0; + local $/; + my $json = <$fh>; + close $fh; + + my $payload = eval { JSON::PP::decode_json($json) }; + exit 0 if !$payload; + + my %seen; + + sub emit_path { + my ($candidate) = @_; + return if !defined $candidate || $candidate eq q{}; + return if $seen{$candidate}++; + print "$candidate\n"; + } + + sub maybe_emit { + my ($command) = @_; + return if !defined $command; + $command =~ s/^\s+|\s+$//g; + return if $command eq q{}; + + if ($command =~ /^\s*(["\047])(\Q$home\E.+?)\1\s*$/) { + emit_path($2); + return; + } + if ($command =~ /^\s*(\Q$home\E\S*)\s*$/) { + emit_path($1); + return; + } + if ($command =~ /^\s*\S+\s+(["\047])(\Q$home\E.+?)\1(?:\s+.*)?$/) { + emit_path($2); + return; + } + if ($command =~ /^\s*\S+\s+(\Q$home\E\S*)(?:\s+.*)?$/) { + emit_path($1); + } + } + + sub walk { + my ($node) = @_; + if (ref $node eq "HASH") { + for my $key (keys %{$node}) { + if ($key eq "command" && !ref $node->{$key}) { + maybe_emit($node->{$key}); + } + walk($node->{$key}); + } + return; + } + if (ref $node eq "ARRAY") { + walk($_) for @{$node}; + } + } + + walk($payload); + ' "$HOST_HOME" "$file" || true +} + +copy_selected_codex_plugins() { + local cfg="$1" + if [[ ! -f "$cfg" ]]; then + return 0 + fi + local refs + refs="$(sed -n 's/^\[plugins\."\([^"]\+\)"\]$/\1/p' "$cfg")" + while IFS= read -r ref; do + [[ -z "$ref" ]] && continue + local name="${ref%@*}" + local provider="${ref#*@}" + [[ "$name" == "$provider" ]] && continue + for candidate in "$HOST_HOME/.codex/plugins/cache/$provider/$name"/*; do + [[ -d "$candidate" ]] || continue + local rel="${candidate#$HOST_HOME/}" + copy_dir "$candidate" "$SANDBOX_HOME/$rel" + done + done <<< "$refs" +} + +mkdir -p "$SANDBOX_HOME" + +copy_file "$HOST_HOME/.claude/settings.json" "$SANDBOX_HOME/.claude/settings.json" +copy_file "$HOST_HOME/.claude/hooks.json" "$SANDBOX_HOME/.claude/hooks.json" +copy_file "$HOST_HOME/.claude/plugins/installed_plugins.json" "$SANDBOX_HOME/.claude/plugins/installed_plugins.json" +copy_file "$HOST_HOME/.codex/config.toml" "$SANDBOX_HOME/.codex/config.toml" +copy_file "$HOST_HOME/.codex/hooks.json" "$SANDBOX_HOME/.codex/hooks.json" + +while IFS= read -r path; do + [[ -f "$path" ]] || continue + rel="${path#$HOST_HOME/}" + copy_file "$path" "$SANDBOX_HOME/$rel" +done < <( + extract_home_paths "$HOST_HOME/.claude/settings.json" + extract_home_paths "$HOST_HOME/.claude/hooks.json" +) + +while IFS= read -r path; do + [[ -d "$path" ]] || continue + rel="${path#$HOST_HOME/}" + copy_dir "$path" "$SANDBOX_HOME/$rel" +done < <(sed -n 's/.*"installPath"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p' "$HOST_HOME/.claude/plugins/installed_plugins.json" 2>/dev/null || true) + +copy_selected_codex_plugins "$HOST_HOME/.codex/config.toml" + +rewrite_home_prefix "$SANDBOX_HOME/.claude/settings.json" +rewrite_home_prefix "$SANDBOX_HOME/.claude/hooks.json" +rewrite_home_prefix "$SANDBOX_HOME/.claude/plugins/installed_plugins.json" +rewrite_home_prefix "$SANDBOX_HOME/.codex/config.toml" +rewrite_home_prefix "$SANDBOX_HOME/.codex/hooks.json" + +echo "Prepared sandbox state in $SANDBOX_HOME" diff --git a/website/docs/reference/appendix/file-structure.md b/website/docs/reference/appendix/file-structure.md index f8f77502..18fa66d1 100644 --- a/website/docs/reference/appendix/file-structure.md +++ b/website/docs/reference/appendix/file-structure.md @@ -31,11 +31,31 @@ Directory layout and file locations for skillshare. │ ├── .agentignore # Optional: exclude agents from sync │ ├── reviewer.md # Agent file │ └── auditor.md # Another agent -├── rules/ # Extras source (if configured) -│ ├── coding.md -│ └── testing.md -└── commands/ # Extras source (if configured) - └── deploy.md +├── plugins/ # Native plugin bundle source +│ └── demo/ +│ ├── .claude-plugin/ +│ │ └── plugin.json +│ ├── .codex-plugin/ +│ │ └── plugin.json +│ └── skillshare.plugin.yaml +├── hooks/ # Standalone hook bundle source +│ └── audit/ +│ ├── hook.yaml +│ └── scripts/ +│ └── pre.sh +├── extras/ # Extras source root (if configured) +│ ├── rules/ +│ │ ├── coding.md +│ │ └── testing.md +│ └── commands/ +│ └── deploy.md +└── rendered/ + └── claude-marketplace/ # Claude plugin render root (global mode) + ├── .claude-plugin/ + │ └── marketplace.json + └── plugins/ + └── demo/ + └── .claude-plugin/plugin.json ~/.local/share/skillshare/ # XDG_DATA_HOME ├── backups/ # Backup directory @@ -90,6 +110,8 @@ XDG_CONFIG_HOME=/custom/path → /custom/path/skillshare/config.yaml # yaml-language-server: $schema=https://raw.githubusercontent.com/runkids/skillshare/main/schemas/config.schema.json source: ~/.config/skillshare/skills agents_source: ~/.config/skillshare/agents # Optional; defaults to /agents +plugins_source: ~/.config/skillshare/plugins # Optional; defaults to /plugins +hooks_source: ~/.config/skillshare/hooks # Optional; defaults to /hooks mode: merge targets: claude: @@ -251,6 +273,86 @@ Agents are a separate resource kind from skills. They live in a sibling source d The agents source is created automatically by `skillshare init` alongside `skills/`. You can override the global location with the `agents_source` config field; project mode always uses `.skillshare/agents/`. +--- + +## Plugin bundles + +Plugin bundles live under: + +```text +~/.config/skillshare/plugins/ # global +.skillshare/plugins/ # project +``` + +Expected bundle shape: + +```text +plugins/ +└── demo/ + ├── .claude-plugin/plugin.json # optional if generated from shared metadata + ├── .codex-plugin/plugin.json # optional if generated from shared metadata + ├── skillshare.plugin.yaml # optional shared metadata + ├── skills/ # optional shared files + ├── assets/ + └── vendor/ +``` + +Rendered/plugin activation paths: + +- Claude rendered marketplace: + - Global: `~/.config/skillshare/rendered/claude-marketplace/` + - Project: `.skillshare/rendered/claude-marketplace/` +- Codex rendered marketplace: + - Global: `~/.agents/plugins/` + - Project: `.agents/plugins/` +- Codex local cache: + - `~/.codex/plugins/cache/skillshare//local/` +- Codex activation config: + - `~/.codex/config.toml` + +In project mode, the plugin source is project-local, but Codex activation still updates the global `~/.codex/config.toml`. + +--- + +## Hook bundles + +Hook bundles live under: + +```text +~/.config/skillshare/hooks/ # global +.skillshare/hooks/ # project +``` + +Expected bundle shape: + +```text +hooks/ +└── audit/ + ├── hook.yaml + └── scripts/ + ├── pre.sh + └── post.sh +``` + +Managed hook state: + +- Claude settings file: + - Global: `~/.claude/settings.json` + - Project: `.claude/settings.json` +- Claude rendered hook root: + - Global: `~/.claude/hooks/skillshare//` + - Project: `.claude/hooks/skillshare//` +- Codex hooks config: + - Global: `~/.codex/hooks.json` + - Project: `.codex/hooks.json` +- Codex rendered hook root: + - Global: `~/.codex/hooks/skillshare//` + - Project: `.codex/hooks/skillshare//` +- Codex feature flag config: + - Global: `~/.codex/config.toml` + +Hook sync merges Skillshare-managed entries into `settings.json` and `hooks.json` while preserving unmanaged entries already present. + ### Agent file format Each agent is a single Markdown file with frontmatter: diff --git a/website/docs/reference/commands/diff.md b/website/docs/reference/commands/diff.md index d1244b71..6124d30e 100644 --- a/website/docs/reference/commands/diff.md +++ b/website/docs/reference/commands/diff.md @@ -230,10 +230,22 @@ skillshare diff --json "exclude": [] } ], + "plugins": [ + {"name": "demo", "target": "claude", "synced": true}, + {"name": "demo", "target": "codex", "synced": false, "items": ["missing rendered state: /home/user/.agents/plugins/demo"]} + ], + "hooks": [ + {"name": "audit", "target": "claude", "synced": true}, + {"name": "audit", "target": "codex", "synced": false, "items": ["missing rendered state: /home/user/.codex/hooks/skillshare/audit"]} + ], "duration": "0.045s" } ``` +Plugin and hook diff sections only include targets that the bundle can actually sync to. For plugins, generated cross-target manifests count as supported targets. For hooks, only defined/syncable target sections are emitted. + +The top-level `plugins` and `hooks` arrays summarize native integration render drift alongside the standard target diff output. + ## See Also - [sync](/docs/reference/commands/sync) — Sync to targets diff --git a/website/docs/reference/commands/doctor.md b/website/docs/reference/commands/doctor.md index 139f215e..c21de70b 100644 --- a/website/docs/reference/commands/doctor.md +++ b/website/docs/reference/commands/doctor.md @@ -20,6 +20,7 @@ skillshare doctor --json # Structured JSON output for CI - Something isn't working and you don't know why - After upgrading skillshare or your OS - Verify all targets, git, and symlinks are healthy +- Check whether plugin and hook bundles have been rendered to their managed roots - First diagnostic step before filing a bug report ## What It Checks @@ -55,6 +56,9 @@ Extras ✓ rules: 4 files, 1/1 targets OK ✓ commands: 3 files, 1/1 targets OK +✓ plugins: All 1 plugin bundle(s) rendered +✓ hooks: All 1 hook bundle(s) rendered + Version ✓ CLI: 0.17.0 ✓ Skill: 0.17.0 @@ -139,6 +143,20 @@ When extras are configured, verifies: - Target directories are reachable - Reports missing source directories or unreachable targets +### Plugins + +When plugin bundles exist, doctor checks whether each bundle has been rendered to its actually supported marketplace roots: + +- Claude: `.skillshare/rendered/claude-marketplace/plugins//` in project mode, or `~/.config/skillshare/rendered/claude-marketplace/plugins//` globally +- Codex: `.agents/plugins//` in project mode, or `~/.agents/plugins//` globally + +### Hooks + +When hook bundles exist, doctor checks whether each supported target-specific managed hook root exists: + +- Claude: `.claude/hooks/skillshare//` or `~/.claude/hooks/skillshare//` +- Codex: `.codex/hooks/skillshare//` or `~/.codex/hooks/skillshare//` + ### Other - Skills without `SKILL.md` files @@ -263,6 +281,12 @@ skillshare doctor --json { "name": "sync_drift", "status": "warning", "message": "claude: 1 skill(s) not synced (7/8 linked)", "details": ["new-skill"] }, { "name": "broken_symlinks", "status": "error", "message": "cursor: 1 broken symlink(s)", "details": ["old-skill"] } ], + "plugins": [ + { "name": "demo", "source_dir": "/home/user/.config/skillshare/plugins/demo", "has_claude": true, "has_codex": true } + ], + "hooks": [ + { "name": "audit", "source_dir": "/home/user/.config/skillshare/hooks/audit", "targets": { "claude": 2, "codex": 1 } } + ], "summary": { "total": 14, "pass": 12, "warnings": 1, "errors": 1, "info": 0 }, "version": { "current": "0.17.4", "latest": "0.18.0", "update_available": true } } diff --git a/website/docs/reference/commands/hooks.md b/website/docs/reference/commands/hooks.md new file mode 100644 index 00000000..86323a39 --- /dev/null +++ b/website/docs/reference/commands/hooks.md @@ -0,0 +1,243 @@ +--- +sidebar_position: 8 +--- + +# hooks + +Manage standalone hook bundles for Claude and Codex. + +```bash +skillshare hooks list +skillshare hooks import --from claude|codex +skillshare hooks sync [name...] --target claude|codex|all +``` + +## What hooks means in skillshare + +Hook bundles are source-managed wrappers around hook scripts plus target-specific event wiring. + +- Global source root: `~/.config/skillshare/hooks/` +- Project source root: `.skillshare/hooks/` +- Supported targets: `claude`, `codex` + +A bundle is a directory containing: + +- `hook.yaml` with `claude:` and/or `codex:` event sections +- optional `scripts/` + +Commands in `hook.yaml` may use `{HOOK_ROOT}`. During sync that placeholder is rewritten to the rendered bundle root for the selected target. + +Claude hook entries are matcher-aware. In `hook.yaml`, each Claude event entry can use the legacy shorthand: + +```yaml +claude: + events: + PreToolUse: + - command: "{HOOK_ROOT}/scripts/pre.sh" +``` + +Or the current matcher-group form flattened into bundle entries: + +```yaml +claude: + events: + PreToolUse: + - matcher: Bash + command: "{HOOK_ROOT}/scripts/pre.sh" + timeout: 8000 + status_message: Enriching with graph context... + if: "Bash(git *)" +``` + +During Claude sync, skillshare renders the current `settings.json` matcher-group shape: + +- Tool events default to matcher `*` +- Non-tool events default to matcher `""` +- Existing unmanaged handlers in the same matcher group are preserved + +## Commands + +### `skillshare hooks list` + +List discovered hook bundles in the current source root. + +```bash +skillshare hooks list +skillshare hooks list --json +``` + +Text output shows event counts per target: + +```text +Hooks +audit claude=2 codex=1 +notify claude=1 codex=0 +``` + +JSON output: + +```json +[ + { + "name": "audit", + "source_dir": "/home/user/.config/skillshare/hooks/audit", + "targets": { + "claude": 2, + "codex": 1 + } + } +] +``` + +### `skillshare hooks import` + +Import hook definitions from local Claude or Codex config into standalone bundles. + +```bash +skillshare hooks import --from claude --all +skillshare hooks import --from claude --owned-only +skillshare hooks import --from codex --all -p +``` + +Behavior: + +- Claude import reads `.claude/settings.json`, and also merges legacy `.claude/hooks.json` when present. +- Codex import reads `.codex/hooks.json`. +- `--all` and `--owned-only` are mutually exclusive. +- `--owned-only` imports only commands already pointing at the Skillshare-managed hook roots. +- `--all` imports every discovered hook entry. +- When import sees a local script path, it copies that file into the bundle `scripts/` directory and rewrites the command to `{HOOK_ROOT}` while preserving wrapper tokens such as `node "..."`. +- When import cannot isolate a local script path, it keeps the command verbatim and records a warning instead of dropping the hook. + +Warning-only import example: + +```yaml +claude: + events: + SessionStart: + - command: "echo hello from shell wrapper" +``` + +That command stays verbatim on import and the bundle records a warning instead of failing import. + +Imported commands are rewritten to use `{HOOK_ROOT}` and copied into `.skillshare/hooks//scripts/` or the global hooks source. + +### `skillshare hooks sync` + +Render scripts into managed hook roots, then merge hook entries back into target config files. + +```bash +skillshare hooks sync +skillshare hooks sync audit --target claude +skillshare hooks sync --target all --json +``` + +Managed roots and config files: + +- Claude: + - Root: `~/.claude/hooks/skillshare//` or `.claude/hooks/skillshare//` + - Config: `~/.claude/settings.json` or `.claude/settings.json` +- Codex: + - Root: `~/.codex/hooks/skillshare//` or `.codex/hooks/skillshare//` + - Config: `~/.codex/hooks.json` or `.codex/hooks.json` + - Feature flag: `~/.codex/config.toml` has `features.codex_hooks = true` + +Sync is merge-based: + +- Managed entries under the Skillshare hook root are updated in place. +- Unmanaged hook entries already present in the config are preserved. +- `--target all` may include successful rows plus warning-only no-op rows such as `no codex hooks defined`. +- A bundle can also render successfully and still emit warnings, for example when unsupported Codex events are skipped. + +JSON output: + +```json +{ + "hooks": [ + { + "name": "audit", + "target": "claude", + "root": "/home/user/.claude/hooks/skillshare/audit", + "merged": true + } + ] +} +``` + +## Supported Codex events + +Codex sync accepts: + +- `PreToolUse` +- `PostToolUse` +- `Notification` +- `SessionStart` +- `SessionEnd` + +Other Codex events are not synced. They surface as warnings such as `unsupported codex event X not synced`. + +Concrete `--target all` example for a Claude-only bundle: + +```json +{ + "hooks": [ + { + "name": "audit", + "target": "claude", + "root": "/home/user/.claude/hooks/skillshare/audit", + "merged": true + }, + { + "name": "audit", + "target": "codex", + "warnings": ["no codex hooks defined"] + } + ] +} +``` + +## Hook bundle flow + +The hook flow is distinct from plugin flow and from skill sync: + +```text +source bundle + -> copy scripts into managed hook root + -> rewrite {HOOK_ROOT} placeholders + -> merge managed entries back into target config + -> preserve unmanaged entries already present +``` + +## Related JSON/report surfaces + +Hook bundles also appear in: + +- [`status --json`](./status.md) +- [`diff --json`](./diff.md) +- [`doctor --json`](./doctor.md) +- [`sync --all --json`](./sync.md) + +Server endpoints: + +- `GET /api/hooks` +- `GET /api/hooks/diff` +- `POST /api/hooks/import` +- `POST /api/hooks/sync` + +## Options + +| Flag | Applies to | Description | +|------|------------|-------------| +| `--json` | `list`, `sync` | Machine-readable output | +| `--from claude|codex` | `import` | Import source | +| `--all` | `import` | Import all hooks | +| `--owned-only` | `import` | Import only Skillshare-managed hooks | +| `--target claude|codex|all` | `sync` | Target selection | +| `--project, -p` | all | Use project mode | +| `--global, -g` | all | Use global mode | + +## See also + +- [plugins](./plugins.md) +- [doctor](./doctor.md) +- [Source & Targets](/docs/understand/source-and-targets) diff --git a/website/docs/reference/commands/index.md b/website/docs/reference/commands/index.md index 90034a45..9de71ab1 100644 --- a/website/docs/reference/commands/index.md +++ b/website/docs/reference/commands/index.md @@ -20,6 +20,8 @@ Complete reference for all skillshare commands. | Temporarily hide a skill without removing it | [`enable` / `disable`](./enable.md) | | Sync across machines | [`push`](./push.md) / [`pull`](./pull.md) | | Manage non-skill resources (rules, commands) | [`extras`](./extras.md) | +| Manage native Claude/Codex plugins | [`plugins`](./plugins.md) | +| Manage standalone Claude/Codex hooks | [`hooks`](./hooks.md) | | Manage single-file `.md` agents | Most commands accept `agents` or `--kind agent` — see [Agents](/docs/understand/agents) | | See which skills use the most context tokens | [`analyze`](./analyze.md) | | Fix something broken | [`doctor`](./doctor.md) | @@ -35,6 +37,7 @@ Complete reference for all skillshare commands. | **Core** | `init`, `install`, `uninstall`, `list`, `search`, `sync`, `status` | | **Skill Management** | `new`, `check`, `update`, `upgrade`, `enable`, `disable` | | **Target Management** | `target`, `diff` | +| **Native Integrations** | `plugins`, `hooks` | | **Extras Management** | `extras` (`init`, `list`, `remove`, `collect`) | | **Sync Operations** | `collect`, `backup`, `restore`, `trash`, `push`, `pull` | | **Security & Utilities** | `analyze`, `audit`, `hub`, `log`, `doctor`, `tui`, `ui`, `completion`, `version` | @@ -76,6 +79,13 @@ Complete reference for all skillshare commands. |---------|-------------| | [extras](./extras.md) | Manage non-skill resources (rules, commands, prompts) | +## Native Integrations + +| Command | Description | +|---------|-------------| +| [plugins](./plugins.md) | Manage native Claude/Codex plugin bundles | +| [hooks](./hooks.md) | Manage standalone Claude/Codex hook bundles | + ## Sync Operations | Command | Description | @@ -131,6 +141,7 @@ skillshare new my-skill # Sync skillshare sync skillshare sync --dry-run +skillshare sync --all --json # Cross-machine skillshare push -m "Add skill" @@ -162,6 +173,10 @@ skillshare tui on # Re-enable TUI skillshare ui skillshare ui -p # Project mode +# Native integrations +skillshare plugins list +skillshare hooks sync --target all + # Hub skillshare hub list skillshare hub add https://hub.example.com/index.json diff --git a/website/docs/reference/commands/plugins.md b/website/docs/reference/commands/plugins.md new file mode 100644 index 00000000..430fb684 --- /dev/null +++ b/website/docs/reference/commands/plugins.md @@ -0,0 +1,220 @@ +--- +sidebar_position: 7 +--- + +# plugins + +Manage native plugin bundles for Claude and Codex. + +```bash +skillshare plugins list +skillshare plugins import --from claude|codex +skillshare plugins sync [name...] --target claude|codex|all +skillshare plugins install --from claude|codex +``` + +## What plugins means in skillshare + +Plugin bundles live in the Skillshare source tree and are separate from skills, agents, and extras: + +- Global source root: `~/.config/skillshare/plugins/` +- Project source root: `.skillshare/plugins/` +- Supported targets: `claude`, `codex` + +Each bundle can contain: + +- `.claude-plugin/plugin.json` +- `.codex-plugin/plugin.json` +- Shared files such as `skills/`, `assets/`, or `vendor/` +- Optional `skillshare.plugin.yaml` metadata used to generate a missing target manifest + +## Commands + +### `skillshare plugins list` + +List discovered plugin bundles in the current source root. + +```bash +skillshare plugins list +skillshare plugins list --json +skillshare plugins list -p +``` + +Text output shows one bundle per line with target availability: + +```text +Plugins +demo claude=true codex=false +audit claude=true codex=true +``` + +JSON output is an array of bundle objects: + +```json +[ + { + "name": "demo", + "source_dir": "/home/user/.config/skillshare/plugins/demo", + "has_claude": true, + "has_codex": false + } +] +``` + +### `skillshare plugins import` + +Import a plugin bundle from a local directory or from local Claude/Codex plugin state. + +```bash +skillshare plugins import demo --from claude +skillshare plugins import audit-tool@skillshare --from codex +skillshare plugins import ./fixtures/my-plugin +``` + +Rules: + +- If the argument is a local directory, `--from` is optional. +- If the argument is a reference, `--from claude|codex` is required. +- Import copies the plugin into the Skillshare source root; it does not activate it on targets. +- Claude import resolves local installs from `~/.claude/plugins/installed_plugins.json` and the recorded `installPath` entries. If a short name matches multiple installed refs, import fails with `use full ref`. +- Codex import resolves local installs from hashed plugin caches under `~/.codex/plugins/cache////` plus configured refs in `~/.codex/config.toml`. If a short name matches multiple refs, import fails with `use full ref`. + +Ambiguous short-name example: + +```bash +skillshare plugins import demo --from claude +# claude plugin "demo" is ambiguous; use full ref + +skillshare plugins import demo@alpha --from claude +``` + +### `skillshare plugins sync` + +Render plugin bundles into target-specific marketplace roots and optionally install/enable them. + +```bash +skillshare plugins sync +skillshare plugins sync demo --target claude +skillshare plugins sync --target all --json +skillshare plugins sync --target codex --no-install +``` + +Behavior: + +- Claude render root: + - Global: `~/.config/skillshare/rendered/claude-marketplace/plugins//` + - Project: `.skillshare/rendered/claude-marketplace/plugins//` +- Codex render root: + - Global: `~/.agents/plugins//` + - Project: `.agents/plugins//` +- Codex install cache: + - Global only: `~/.codex/plugins/cache/skillshare//local/` + +With installation enabled, sync also: + +- Updates marketplace indexes for Claude/Codex +- Runs the Claude plugin install/update/enable flow. The install vs update decision uses Claude installed-plugin metadata, not bare directory heuristics. +- Enables the Codex plugin in `~/.codex/config.toml` + +Project nuance: + +- Project-scoped plugin source is supported. +- Codex plugin activation still updates the global `~/.codex/config.toml`. + +JSON output: + +```json +{ + "plugins": [ + { + "name": "demo", + "target": "codex", + "rendered": "/home/user/.agents/plugins/demo", + "installed": true, + "generated": false + } + ] +} +``` + +### `skillshare plugins install` + +Convenience wrapper for `import` plus `sync`. + +```bash +skillshare plugins install demo --from claude +skillshare plugins install ./fixtures/my-plugin --from codex --target codex +``` + +This command: + +1. Imports the plugin bundle into the Skillshare source root. +2. Syncs plugins to the selected target or `all`. +3. Installs/enables the plugin unless you use `plugins sync --no-install` directly instead. + +## How plugin bundles translate across targets + +The plugin flow is distinct from skill sync: + +```text +source bundle + -> stage bundle for one target + -> copy shared files + -> keep existing native manifest or generate one from shared metadata + -> render into marketplace root + -> optionally install/enable in the target runtime +``` + +If only one native manifest exists, skillshare can generate the missing target manifest from: + +- the existing native manifest, plus +- `skillshare.plugin.yaml` shared metadata, when present + +That generated-target capability is also reflected in JSON surfaces such as `diff --json`, `/api/plugins/diff`, and doctor checks, so those views only report targets a bundle can actually sync to. + +Warnings may be emitted when translation skips unsupported shared directories such as `commands`, `agents`, or `hooks`. + +Concrete generated-target example: + +```text +bundle source: + .claude-plugin/plugin.json + skillshare.plugin.yaml + +skillshare plugins sync demo --target all +``` + +That single bundle can sync to both Claude and Codex because Skillshare generates the missing `.codex-plugin/plugin.json` from shared metadata. + +## Related JSON/report surfaces + +Plugin bundles also appear in: + +- [`status --json`](./status.md) +- [`diff --json`](./diff.md) +- [`doctor --json`](./doctor.md) +- [`sync --all --json`](./sync.md) + +Server endpoints: + +- `GET /api/plugins` +- `GET /api/plugins/diff` +- `POST /api/plugins/import` +- `POST /api/plugins/sync` + +## Options + +| Flag | Applies to | Description | +|------|------------|-------------| +| `--json` | `list`, `sync` | Machine-readable output | +| `--from claude|codex` | `import`, `install` | Import source | +| `--target claude|codex|all` | `sync`, `install` | Target selection | +| `--no-install` | `sync` | Render only; skip target activation | +| `--project, -p` | all | Use project mode | +| `--global, -g` | all | Use global mode | + +## See also + +- [hooks](./hooks.md) +- [sync](./sync.md) +- [Source & Targets](/docs/understand/source-and-targets) diff --git a/website/docs/reference/commands/status.md b/website/docs/reference/commands/status.md index cb892d8c..7b84af55 100644 --- a/website/docs/reference/commands/status.md +++ b/website/docs/reference/commands/status.md @@ -17,6 +17,7 @@ skillshare status - Verify tracked repos are up to date - Verify the active audit policy (profile, threshold, dedupe mode) - Check for CLI or skill updates +- See whether plugin and hook bundles are present in the current source root ![status demo](/img/status-demo.png) @@ -47,6 +48,12 @@ Extras rules has files [merge] .cursor/rules (4 files) commands has files [merge] .claude/commands (3 files) +Plugins +demo plugin claude=true codex=true + +Hooks +audit hook claude=2 codex=1 + Audit → Profile: DEFAULT → Block: severity >= CRITICAL @@ -113,6 +120,14 @@ commands has files [merge] .claude/commands (3 files) Each entry shows the name, status, sync mode, target path, and file count. +### Plugins + +When plugin bundles exist in the current source root, `status` lists each bundle and whether it has Claude and/or Codex manifests available. + +### Hooks + +When hook bundles exist in the current source root, `status` lists each bundle and the number of hook entries defined for Claude and Codex. + ### Audit Shows the active audit policy configuration (resolved from CLI flags, project config, or global config): @@ -178,6 +193,24 @@ skillshare status --json {"name": "claude", "path": "~/.claude/agents", "expected": 8, "linked": 8, "drift": false} ] }, + "plugins": [ + { + "name": "demo", + "source_dir": "/home/user/.config/skillshare/plugins/demo", + "has_claude": true, + "has_codex": true + } + ], + "hooks": [ + { + "name": "audit", + "source_dir": "/home/user/.config/skillshare/hooks/audit", + "targets": { + "claude": 2, + "codex": 1 + } + } + ], "audit": { "profile": "DEFAULT", "threshold": "CRITICAL", @@ -190,7 +223,7 @@ skillshare status --json The `source.skillignore` field is present only when at least one `.skillignore` or `.skillignore.local` file exists. When absent: `"skillignore": { "active": false }`. The `files` array includes `.skillignore.local` paths when present. In text mode, the source line shows `.local active` when any `.skillignore.local` is in effect. -JSON output is supported in both global and project mode. +JSON output is supported in both global and project mode. The top-level `plugins` and `hooks` arrays are omitted when no plugin or hook bundles are discovered. Plugin bundles report generated-target capability, and hook bundles report only the target sections they actually define. ## Project Mode diff --git a/website/docs/reference/commands/sync.md b/website/docs/reference/commands/sync.md index 361ad6e2..d7e12b75 100644 --- a/website/docs/reference/commands/sync.md +++ b/website/docs/reference/commands/sync.md @@ -87,7 +87,7 @@ Push skills from source to all targets. ```bash skillshare sync # Sync skills to all targets skillshare sync agents # Sync agents only -skillshare sync --all # Sync skills + agents + extras +skillshare sync --all # Sync skills + agents + extras + plugins + hooks skillshare sync --dry-run # Preview changes skillshare sync -n # Short form skillshare sync --force # Overwrite all managed skills @@ -96,7 +96,7 @@ skillshare sync -f # Short form | Flag | Short | Description | |------|-------|-------------| -| `--all` | | Also sync agents and extras after skills | +| `--all` | | Also sync agents, extras, plugins, and hooks after skills | | `--dry-run` | `-n` | Preview changes without writing | | `--force` | `-f` | Overwrite all managed entries regardless of checksum (copy mode) or replace existing directories with symlinks (merge mode) | | `--json` | | Output as JSON | @@ -145,10 +145,30 @@ skillshare sync --json "on_demand_tokens": 58200 } ] - } + }, + "plugins": [ + { + "name": "demo", + "target": "codex", + "rendered": "/home/user/.agents/plugins/demo", + "installed": true + } + ], + "hooks": [ + { + "name": "audit", + "target": "claude", + "root": "/home/user/.claude/hooks/skillshare/audit", + "merged": true + } + ] } ``` +When `--all` is used, JSON output can also include top-level `extras`, `plugins`, and `hooks` sections describing the follow-on sync work after the core skills pass. + +Overall success is still possible when those follow-on sections contain warnings or target-specific no-op rows. Example: a Claude-only hook bundle synced with `--target all` can produce a successful Claude result plus a Codex warning row saying `no codex hooks defined`. + The `ignored_count` and `ignored_skills` fields show skills excluded by `.skillignore` (and `.skillignore.local` if present). These are filtered at discovery time and never reach any target. When `.skillignore.local` is active, the text output includes a `.local` source hint. See [.skillignore](/docs/reference/appendix/file-structure#skillignore-optional) for pattern syntax. ### What Happens @@ -160,14 +180,36 @@ flowchart TD S2["2. For each target"] MERGE["merge mode"] SYMLINK["symlink mode"] - S3["3. Report results"] + S3["3. Optional resource sync +agents + extras + plugins + hooks"] + S4["4. Report results"] TITLE --> S1 --> S2 COPY["copy mode"] S2 --> MERGE --> S3 S2 --> COPY --> S3 S2 --> SYMLINK --> S3 + S3 --> S4 ``` +## `sync --all` resource coverage + +`skillshare sync --all` is the umbrella command for these source-managed resource kinds: + +- `skills` +- `agents` +- `extras` +- `plugins` +- `hooks` + +The resource-specific flows remain separate: + +- Skills and agents sync through target path management. +- Extras sync file trees to configured targets. +- Plugins render into Claude/Codex marketplace roots and may install/enable natively. +- Hooks render scripts into managed roots and merge references back into Claude/Codex config files. + +See [plugins](./plugins.md) and [hooks](./hooks.md) for the native integration details. + ### Example Output

diff --git a/website/docs/reference/targets/configuration.md b/website/docs/reference/targets/configuration.md index a5b1f15a..9466ab9a 100644 --- a/website/docs/reference/targets/configuration.md +++ b/website/docs/reference/targets/configuration.md @@ -16,6 +16,8 @@ Configuration file reference for skillshare. │ ├── my-skill/ │ ├── another/ │ └── _team-repo/ ← Tracked repository +├── plugins/ ← Plugin bundle source +├── hooks/ ← Hook bundle source ├── extras/ ← Extras source root │ └── rules/ ← Extra resource (e.g., rules) @@ -117,6 +119,10 @@ skills: # Custom agents source (optional, overrides default location) agents_source: ~/my-agents +# Custom plugin and hook sources (optional, override defaults) +plugins_source: ~/my-plugins +hooks_source: ~/my-hooks + # Custom extras source (optional, overrides default location) extras_source: ~/my-extras @@ -850,6 +856,39 @@ Uses NTFS junctions (no admin required). --- +### `plugins_source` + +Path to the native plugin bundle source directory. + +```yaml +plugins_source: ~/.config/skillshare/plugins +``` + +Default: + +- Global mode: `~/.config/skillshare/plugins` +- Project mode: fixed at `.skillshare/plugins` + +### `hooks_source` + +Path to the standalone hook bundle source directory. + +```yaml +hooks_source: ~/.config/skillshare/hooks +``` + +Default: + +- Global mode: `~/.config/skillshare/hooks` +- Project mode: fixed at `.skillshare/hooks` + +Notes: + +- Project mode always uses the fixed `.skillshare/plugins` and `.skillshare/hooks` roots. +- Plugin and hook management currently target only Claude and Codex. + +--- + ## Related - [Source & Targets](/docs/understand/source-and-targets) — Core concepts diff --git a/website/docs/reference/targets/supported-targets.md b/website/docs/reference/targets/supported-targets.md index 56502a51..bc17dd57 100644 --- a/website/docs/reference/targets/supported-targets.md +++ b/website/docs/reference/targets/supported-targets.md @@ -10,6 +10,8 @@ Complete list of AI CLIs that skillshare supports out of the box. skillshare supports **64+ AI CLI tools**. When you run `skillshare init`, it automatically detects and configures any installed tools. +The built-in target table below describes **skill target paths**. Other resource kinds have different support coverage; see the support matrix later on this page. + --- ## Built-in Targets @@ -205,6 +207,25 @@ Aliases are resolved automatically. The canonical name is used in config files a --- +## Resource Support Matrix + +| Resource | Built-in target support | +|----------|-------------------------| +| `skills` | All built-in targets with a skills path | +| `agents` | `augment`, `claude`, `cursor`, `opencode` | +| `plugins` | `claude`, `codex` | +| `hooks` | `claude`, `codex` | +| `extras` | Path-configurable; support depends on configured target paths, not built-in target names | + +Notes: + +- `agents` are intentionally limited to targets with explicit agent directory support. +- `plugins` and `hooks` are native integration subsystems, not generic path-based skill sync. +- Codex plugin activation still writes the global `~/.codex/config.toml`, even when the plugin source itself is project-scoped. +- Claude plugin rendering uses a Skillshare-managed marketplace root rather than writing directly into `~/.claude/plugins/`. + +--- + ## Check Target Path For any target, run: diff --git a/website/docs/understand/source-and-targets.md b/website/docs/understand/source-and-targets.md index c132f0d7..2230f1c5 100644 --- a/website/docs/understand/source-and-targets.md +++ b/website/docs/understand/source-and-targets.md @@ -7,7 +7,7 @@ sidebar_position: 2 The core model behind skillshare: one source, many targets. :::tip When does this matter? -Understanding source vs targets helps you know where to edit skills and agents (always in source — changes reflect via symlinks), why `sync` is a separate step, and how `collect` works in the reverse direction. +Understanding source vs targets helps you know where to edit skills, agents, plugins, hooks, and extras, why `sync` is a separate step for some resource kinds, and how `collect` or import flows work in the reverse direction. ::: ## The Problem @@ -34,29 +34,29 @@ Without skillshare, you manage skills separately for each AI CLI: ## The Solution -skillshare introduces a **source directory** that syncs to all **targets**: +skillshare introduces source-managed resource roots that sync to targets or target config: ```mermaid flowchart TD - SRC["SOURCE — ~/.config/skillshare/skills/"] - TGT_CLAUDE["~/.claude/skills/"] - TGT_CURSOR["~/.cursor/skills/"] - TGT_CODEX["~/.codex/skills/"] - SRC -->|"sync"| TGT_CLAUDE - SRC -->|"sync"| TGT_CURSOR - SRC -->|"sync"| TGT_CODEX + SRC["skills/ + agents/ + extras/ + plugins/ + hooks/"] + TGT_CLAUDE["Claude"] + TGT_CURSOR["Cursor"] + TGT_CODEX["Codex"] + SRC -->|"resource-specific sync"| TGT_CLAUDE + SRC -->|"resource-specific sync"| TGT_CURSOR + SRC -->|"resource-specific sync"| TGT_CODEX ``` **Benefits:** -- Edit in source → all targets update instantly -- Edit in target → changes go to source (via symlinks) +- Edit in source → targets or target config can be regenerated consistently +- Skills and agents can reflect edits instantly through symlinks - Single source of truth --- ## Why Sync is a Separate Step -Operations like `install`, `update`, and `uninstall` only modify the **source** directory. A separate `sync` step propagates changes to all targets. This two-phase design is intentional: +Operations like `install`, `update`, `uninstall`, `plugins import`, and `hooks import` only modify the **source** side. A separate `sync` step propagates changes to targets or target config. This two-phase design is intentional: **Preview before propagating** — Run `sync --dry-run` to review what will change across all targets before applying. Especially useful after `uninstall` or `--force` operations. @@ -69,7 +69,7 @@ Operations like `install`, `update`, and `uninstall` only modify the **source** ::: :::info When sync is NOT needed -Editing an existing skill doesn't require sync — symlinks mean changes are instantly visible in all targets. You only need sync when the set of skills changes (add, remove, rename) or when targets/modes change. +Editing an existing skill or agent usually doesn't require sync because symlinks mean changes are instantly visible in linked targets. Plugins, hooks, and extras still require explicit sync because they render into managed roots or config files. ::: --- @@ -201,6 +201,69 @@ The same feature exists in project mode (see [Project Skills](/docs/understand/p --- +## Plugin source + +Plugins are their own source-managed subsystem: + +```text +~/.config/skillshare/plugins/ # global +.skillshare/plugins/ # project +``` + +A plugin bundle is not synced like a skill directory. The flow is: + +```text +source bundle + -> target-specific staged bundle + -> rendered marketplace root + -> optional install/enable step +``` + +Target render roots: + +- Claude: + - Global: `~/.config/skillshare/rendered/claude-marketplace/` + - Project: `.skillshare/rendered/claude-marketplace/` +- Codex: + - Global: `~/.agents/plugins/` + - Project: `.agents/plugins/` + +Codex activation is still global because enablement writes `~/.codex/config.toml`, even when the plugin source itself is project-scoped. + +--- + +## Hook source + +Hooks are another separate subsystem: + +```text +~/.config/skillshare/hooks/ # global +.skillshare/hooks/ # project +``` + +The hook flow is: + +```text +source bundle + -> managed hook script root + -> merge managed entries back into target config +``` + +Managed config files: + +- Claude: `.claude/settings.json` or `~/.claude/settings.json` +- Codex: `.codex/hooks.json` or `~/.codex/hooks.json` +- Codex also enables `features.codex_hooks = true` in `~/.codex/config.toml` + +This merge model preserves unmanaged hook entries that already exist in those config files. + +For native resources, reporting stays target-aware: + +- plugin reporting includes only targets the bundle can actually sync to, including generated manifests +- hook reporting includes only target sections defined in `hook.yaml` + +--- + ## Targets Targets are AI CLI skill directories that skillshare syncs to. @@ -269,6 +332,12 @@ $EDITOR ~/.claude/skills/my-skill/SKILL.md - [sync](/docs/reference/commands/sync) — Propagate changes from source to targets - [collect](/docs/reference/commands/collect) — Pull skills from targets back to source +- [plugins](/docs/reference/commands/plugins) — Native plugin bundle flow +- [hooks](/docs/reference/commands/hooks) — Standalone hook bundle flow - [Sync Modes](./sync-modes.md) — How files are linked (merge, copy, symlink) - [Agents](./agents.md) — Agent resource model and discovery - [Configuration](/docs/reference/targets/configuration) — Target config reference + +:::note Current web UI scope +The web UI exposes skills, targets, extras, and related operations, but it does not yet have dedicated plugin or hook screens. Use the CLI or server API endpoints for plugin/hook workflows. +::: diff --git a/website/sidebars.ts b/website/sidebars.ts index 76c5d532..05893309 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -158,6 +158,8 @@ const sidebars: SidebarsConfig = { items: [ 'reference/commands/collect', 'reference/commands/extras', + 'reference/commands/plugins', + 'reference/commands/hooks', 'reference/commands/backup', 'reference/commands/restore', 'reference/commands/trash', From 337d60a02dcb218379a2f61401d424a5c3a66b0e Mon Sep 17 00:00:00 2001 From: talvak Date: Tue, 21 Apr 2026 16:39:41 +0300 Subject: [PATCH 2/2] fix hook and codex sync edge cases --- cmd/skillshare/doctor.go | 6 + cmd/skillshare/hooks.go | 9 +- cmd/skillshare/hooks_test.go | 30 +++++ cmd/skillshare/init_test.go | 1 + cmd/skillshare/status.go | 9 +- cmd/skillshare/status_project.go | 9 +- internal/hooks/hooks.go | 178 ++++++++++++++++++--------- internal/hooks/hooks_test.go | 137 +++++++++++++++++++++ internal/plugins/plugins.go | 60 ++++++--- internal/plugins/plugins_test.go | 26 ++++ internal/tooling/fs.go | 201 ++++++++++++++++++++++++------- internal/tooling/fs_test.go | 34 ++++++ 12 files changed, 583 insertions(+), 117 deletions(-) create mode 100644 internal/tooling/fs_test.go diff --git a/cmd/skillshare/doctor.go b/cmd/skillshare/doctor.go index 13475a4d..f34c574f 100644 --- a/cmd/skillshare/doctor.go +++ b/cmd/skillshare/doctor.go @@ -1167,6 +1167,12 @@ func checkHooks(sourceRoot, projectRoot string, result *doctorResult) { } var details []string for _, bundle := range bundles { + if len(bundle.Issues) > 0 { + for _, issue := range bundle.Issues { + details = append(details, fmt.Sprintf("%s: %s", bundle.Name, issue)) + } + continue + } for _, target := range hookpkg.SupportedTargets(bundle) { if _, err := os.Stat(hookpkg.RenderRoot(projectRoot, bundle.Name, target)); err != nil { details = append(details, fmt.Sprintf("%s: %s hooks not rendered", bundle.Name, target)) diff --git a/cmd/skillshare/hooks.go b/cmd/skillshare/hooks.go index 4e6e7dc8..7e423536 100644 --- a/cmd/skillshare/hooks.go +++ b/cmd/skillshare/hooks.go @@ -55,7 +55,14 @@ func cmdHooksList(args []string) error { } ui.Header(ui.WithModeLabel("Hooks")) for _, bundle := range bundles { - ui.Info("%s claude=%d codex=%d", bundle.Name, bundle.Targets["claude"], bundle.Targets["codex"]) + summary := fmt.Sprintf("claude=%d codex=%d", bundle.Targets["claude"], bundle.Targets["codex"]) + if len(bundle.Issues) > 0 { + summary += fmt.Sprintf(" issues=%d", len(bundle.Issues)) + } + ui.Info("%s %s", bundle.Name, summary) + for _, issue := range bundle.Issues { + ui.Info(" %s", issue) + } } return nil } diff --git a/cmd/skillshare/hooks_test.go b/cmd/skillshare/hooks_test.go index 7a2b4fb7..8789d0ce 100644 --- a/cmd/skillshare/hooks_test.go +++ b/cmd/skillshare/hooks_test.go @@ -117,3 +117,33 @@ func TestCmdHooksSyncTextShowsWarningsForRenderedBundles(t *testing.T) { t.Fatalf("expected warning output, got %s", output) } } + +func TestCmdHooksSyncTextShowsInvalidBundleWarnings(t *testing.T) { + root := t.TempDir() + oldWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(oldWD) + }) + + if err := os.MkdirAll(filepath.Join(root, ".skillshare", "hooks", "broken"), 0o755); err != nil { + t.Fatalf("mkdir broken: %v", err) + } + if err := os.WriteFile(filepath.Join(root, ".skillshare", "hooks", "broken", "hook.yaml"), []byte("claude:\n events:\n SessionStart:\n - command: \"{OTHER_ROOT}/scripts/start.sh\"\n"), 0o644); err != nil { + t.Fatalf("write broken hook: %v", err) + } + + output := stripANSIWarnings(captureStdout(t, func() { + if err := cmdHooksSync([]string{"-p", "broken", "--target", "all"}); err != nil { + t.Fatalf("cmdHooksSync: %v", err) + } + })) + if !strings.Contains(output, "broken:") || !strings.Contains(output, "hook.yaml") { + t.Fatalf("expected invalid bundle warning output, got %s", output) + } +} diff --git a/cmd/skillshare/init_test.go b/cmd/skillshare/init_test.go index 59c8bb46..b6acd5d7 100644 --- a/cmd/skillshare/init_test.go +++ b/cmd/skillshare/init_test.go @@ -36,6 +36,7 @@ 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", filepath.Join(".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/status.go b/cmd/skillshare/status.go index eb68b209..6adb6282 100644 --- a/cmd/skillshare/status.go +++ b/cmd/skillshare/status.go @@ -147,7 +147,14 @@ func cmdStatus(args []string) error { if bundles, bundleErr := hookpkg.Discover(cfg.EffectiveHooksSource()); bundleErr == nil && len(bundles) > 0 { ui.Header("Hooks") for _, bundle := range bundles { - ui.Status(bundle.Name, "hook", fmt.Sprintf("claude=%d codex=%d", bundle.Targets["claude"], bundle.Targets["codex"])) + summary := fmt.Sprintf("claude=%d codex=%d", bundle.Targets["claude"], bundle.Targets["codex"]) + if len(bundle.Issues) > 0 { + summary += fmt.Sprintf(" issues=%d", len(bundle.Issues)) + } + ui.Status(bundle.Name, "hook", summary) + for _, issue := range bundle.Issues { + ui.Info(" %s", issue) + } } } diff --git a/cmd/skillshare/status_project.go b/cmd/skillshare/status_project.go index 362fdf62..9a076be2 100644 --- a/cmd/skillshare/status_project.go +++ b/cmd/skillshare/status_project.go @@ -59,7 +59,14 @@ func cmdStatusProject(root string) error { if bundles, bundleErr := hookpkg.Discover(config.HooksSourceDirProject(root)); bundleErr == nil && len(bundles) > 0 { ui.Header("Hooks") for _, bundle := range bundles { - ui.Status(bundle.Name, "hook", fmt.Sprintf("claude=%d codex=%d", bundle.Targets["claude"], bundle.Targets["codex"])) + summary := fmt.Sprintf("claude=%d codex=%d", bundle.Targets["claude"], bundle.Targets["codex"]) + if len(bundle.Issues) > 0 { + summary += fmt.Sprintf(" issues=%d", len(bundle.Issues)) + } + ui.Status(bundle.Name, "hook", summary) + for _, issue := range bundle.Issues { + ui.Info(" %s", issue) + } } } diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 839014e4..85e7a5e1 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -126,6 +126,12 @@ func Discover(sourceRoot string) ([]Bundle, error) { dir := filepath.Join(sourceRoot, entry.Name()) cfg, warnings, err := readHookConfig(filepath.Join(dir, "hook.yaml")) if err != nil { + out = append(out, Bundle{ + Name: entry.Name(), + SourceDir: dir, + Targets: map[string]int{}, + Issues: []string{err.Error()}, + }) continue } targets := map[string]int{} @@ -201,6 +207,9 @@ func SyncAll(sourceRoot, projectRoot, target string) ([]SyncResult, error) { } func SyncBundle(bundle Bundle, projectRoot, target string) (SyncResult, error) { + if len(bundle.Issues) > 0 { + return SyncResult{Name: bundle.Name, Target: target, Warnings: append([]string{}, bundle.Issues...)}, nil + } switch target { case "claude": if bundle.Config.Claude == nil { @@ -409,11 +418,15 @@ func decodeHookMap(raw any) map[string][]map[string]any { if raw == nil { return result } - data, err := json.Marshal(raw) - if err != nil { + root, ok := anyMap(raw) + if !ok { return result } - _ = json.Unmarshal(data, &result) + for event, payload := range root { + for _, entry := range anySliceOfMaps(payload) { + result[event] = append(result[event], entry) + } + } return result } @@ -422,19 +435,15 @@ func decodeClaudeHookMap(raw any) map[string][]claudeMatcherGroup { if raw == nil { return result } - payload := map[string][]map[string]any{} - data, err := json.Marshal(raw) - if err != nil { - return result - } - if err := json.Unmarshal(data, &payload); err != nil { + payload, ok := anyMap(raw) + if !ok { return result } for event, groups := range payload { - for _, rawGroup := range groups { + for _, rawGroup := range anySliceOfMaps(groups) { group := claudeMatcherGroup{Extra: map[string]any{}} if hooksRaw, ok := rawGroup["hooks"]; ok { - group.Matcher = rawGroup["matcher"] + group.Matcher = deepCloneAny(rawGroup["matcher"]) if group.Matcher == nil { group.Matcher = defaultClaudeMatcher(event) } @@ -442,9 +451,9 @@ func decodeClaudeHookMap(raw any) map[string][]claudeMatcherGroup { if key == "hooks" || key == "matcher" { continue } - group.Extra[key] = value + group.Extra[key] = deepCloneAny(value) } - for _, hook := range decodeJSONArrayOfMaps(hooksRaw) { + for _, hook := range anySliceOfMaps(hooksRaw) { group.Hooks = append(group.Hooks, hook) } } else { @@ -762,26 +771,21 @@ func buildLocalizedImportCommand(original, managedRoot, srcPath, prefix, quote, } func parseDirectExecutableCommand(command string) (string, string, string, bool) { - if len(command) == 0 { + if strings.TrimSpace(command) == "" { return "", "", "", false } if command[0] == '"' || command[0] == '\'' { - quote := string(command[0]) - end := strings.Index(command[1:], quote) - if end < 0 { + path, rest, quote, ok := parseQuotedCommandPath(command) + if !ok { return "", "", "", false } - path := command[1 : 1+end] if filepath.IsAbs(path) { - return path, quote, command[1+end+1:], true + return path, quote, rest, true } return "", "", "", false } - if !filepath.IsAbs(strings.Fields(command)[0]) { - return "", "", "", false - } fields := strings.Fields(command) - if len(fields) == 0 { + if len(fields) == 0 || !filepath.IsAbs(fields[0]) { return "", "", "", false } path := fields[0] @@ -805,16 +809,14 @@ func parseInterpreterScriptCommand(command string) (string, string, string, stri return "", "", "", "", false } if remaining[0] == '"' || remaining[0] == '\'' { - quote := string(remaining[0]) - end := strings.Index(remaining[1:], quote) - if end < 0 { + path, rest, quote, ok := parseQuotedCommandPath(remaining) + if !ok { return "", "", "", "", false } - path := remaining[1 : 1+end] if !filepath.IsAbs(path) { return "", "", "", "", false } - return prefix, path, quote, remaining[1+end+1:], true + return prefix, path, quote, rest, true } second := strings.Fields(remaining) if len(second) == 0 || !filepath.IsAbs(second[0]) { @@ -924,19 +926,12 @@ func writeImportedGroups(sourceRoot string, groups map[string]importGroup, targe func copyImportedHookFiles(dir string, files map[string]string) error { for srcPath, rel := range files { - data, err := os.ReadFile(srcPath) - if err != nil { - return err - } dst := filepath.Join(dir, "scripts", filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - return err - } info, err := os.Stat(srcPath) if err != nil { return err } - if err := os.WriteFile(dst, data, info.Mode()); err != nil { + if err := tooling.CopyFile(srcPath, dst, info.Mode()); err != nil { return err } } @@ -990,15 +985,7 @@ func matcherSignature(matcher any) string { } func cloneMatcher(matcher any) any { - data, err := json.Marshal(matcher) - if err != nil { - return matcher - } - var out any - if err := json.Unmarshal(data, &out); err != nil { - return matcher - } - return out + return deepCloneAny(matcher) } func cloneAnyMap(src map[string]any) map[string]any { @@ -1007,7 +994,7 @@ func cloneAnyMap(src map[string]any) map[string]any { } dst := make(map[string]any, len(src)) for key, value := range src { - dst[key] = value + dst[key] = deepCloneAny(value) } return dst } @@ -1029,16 +1016,6 @@ func mergeAnyMap(dst map[string]any, src map[string]any) { } } -func decodeJSONArrayOfMaps(raw any) []map[string]any { - var items []map[string]any - data, err := json.Marshal(raw) - if err != nil { - return nil - } - _ = json.Unmarshal(data, &items) - return items -} - func cloneHandlerSlice(src []map[string]any) []map[string]any { if len(src) == 0 { return nil @@ -1056,7 +1033,7 @@ func isManagedCommandHandler(handler map[string]any, managedPrefix string) bool } func anyStringMap(raw any) map[string]string { - items, ok := raw.(map[string]any) + items, ok := anyMap(raw) if !ok { return nil } @@ -1070,7 +1047,7 @@ func anyStringMap(raw any) map[string]string { } func anyStringSlice(raw any) []string { - list, ok := raw.([]any) + list, ok := anySlice(raw) if !ok { if typed, ok := raw.([]string); ok { return append([]string{}, typed...) @@ -1086,6 +1063,91 @@ func anyStringSlice(raw any) []string { return out } +func anyMap(raw any) (map[string]any, bool) { + switch value := raw.(type) { + case map[string]any: + return value, true + case map[any]any: + out := make(map[string]any, len(value)) + for key, item := range value { + ks, ok := key.(string) + if !ok { + return nil, false + } + out[ks] = item + } + return out, true + default: + return nil, false + } +} + +func anySlice(raw any) ([]any, bool) { + list, ok := raw.([]any) + return list, ok +} + +func anySliceOfMaps(raw any) []map[string]any { + list, ok := anySlice(raw) + if !ok { + return nil + } + out := make([]map[string]any, 0, len(list)) + for _, item := range list { + if mapped, ok := anyMap(item); ok { + out = append(out, cloneAnyMap(mapped)) + } + } + return out +} + +func deepCloneAny(raw any) any { + switch value := raw.(type) { + case map[string]any: + return cloneAnyMap(value) + case map[any]any: + mapped, ok := anyMap(value) + if !ok { + return value + } + return cloneAnyMap(mapped) + case []any: + out := make([]any, 0, len(value)) + for _, item := range value { + out = append(out, deepCloneAny(item)) + } + return out + default: + return value + } +} + +func parseQuotedCommandPath(command string) (string, string, string, bool) { + if len(command) == 0 { + return "", "", "", false + } + quote := command[0] + var path strings.Builder + escaped := false + for i := 1; i < len(command); i++ { + ch := command[i] + if escaped { + path.WriteByte(ch) + escaped = false + continue + } + if quote == '"' && ch == '\\' { + escaped = true + continue + } + if ch == quote { + return path.String(), command[i+1:], string(quote), true + } + path.WriteByte(ch) + } + return "", "", "", false +} + func anyInt(raw any) int { switch value := raw.(type) { case float64: diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go index b370663d..0307d35c 100644 --- a/internal/hooks/hooks_test.go +++ b/internal/hooks/hooks_test.go @@ -307,6 +307,143 @@ func TestImportClaudeHooksOwnedOnlyAndConflict(t *testing.T) { } } +func TestDiscoverIncludesInvalidBundleIssues(t *testing.T) { + root := filepath.Join(t.TempDir(), "hooks") + writeHookBundle(t, filepath.Join(root, "valid"), ` +claude: + events: + SessionStart: + - command: "{HOOK_ROOT}/scripts/start.sh" +`) + writeHookBundle(t, filepath.Join(root, "broken"), ` +claude: + events: + SessionStart: + - command: "{OTHER_ROOT}/scripts/start.sh" +`) + + bundles, err := Discover(root) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if len(bundles) != 2 { + t.Fatalf("expected 2 bundles, got %+v", bundles) + } + + var broken Bundle + for _, bundle := range bundles { + if bundle.Name == "broken" { + broken = bundle + break + } + } + if broken.Name == "" { + t.Fatalf("broken bundle missing from %+v", bundles) + } + if len(broken.Issues) == 0 { + t.Fatalf("expected discovery issues for broken bundle: %+v", broken) + } + if len(broken.Targets) != 0 { + t.Fatalf("expected invalid bundle to have no targets, got %+v", broken.Targets) + } +} + +func TestSyncAllReturnsWarningRowForInvalidBundle(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + sourceRoot := filepath.Join(t.TempDir(), "hooks") + writeHookBundle(t, filepath.Join(sourceRoot, "broken"), ` +claude: + events: + SessionStart: + - command: "{OTHER_ROOT}/scripts/start.sh" +`) + + results, err := SyncAll(sourceRoot, "", "all") + if err != nil { + t.Fatalf("SyncAll() error = %v", err) + } + if len(results) != 2 { + t.Fatalf("expected warning rows for both targets, got %+v", results) + } + for _, res := range results { + if res.Name != "broken" { + t.Fatalf("unexpected result row: %+v", res) + } + if res.Root != "" || res.Merged { + t.Fatalf("expected warning-only result, got %+v", res) + } + if len(res.Warnings) == 0 || !strings.Contains(strings.Join(res.Warnings, "\n"), "hook.yaml") { + t.Fatalf("expected surfaced issue in warnings, got %+v", res) + } + } +} + +func TestParseDirectExecutableCommandRejectsWhitespaceOnly(t *testing.T) { + if _, _, _, ok := parseDirectExecutableCommand(" \t "); ok { + t.Fatal("expected whitespace-only command to fail") + } +} + +func TestParseDirectExecutableCommandSupportsEscapedQuotes(t *testing.T) { + command := "\"/tmp/a\\\"b/script.sh\" --flag" + path, quote, rest, ok := parseDirectExecutableCommand(command) + if !ok { + t.Fatalf("expected parse success for %q", command) + } + if path != `/tmp/a"b/script.sh` || quote != `"` || rest != " --flag" { + t.Fatalf("unexpected parse result path=%q quote=%q rest=%q", path, quote, rest) + } +} + +func TestParseInterpreterScriptCommandSupportsEscapedQuotes(t *testing.T) { + command := "node \"/tmp/a\\\"b/script.js\" --watch" + prefix, path, quote, suffix, ok := parseInterpreterScriptCommand(command) + if !ok { + t.Fatalf("expected parse success for %q", command) + } + if prefix != "node " || path != `/tmp/a"b/script.js` || quote != `"` || suffix != " --watch" { + t.Fatalf("unexpected parse result prefix=%q path=%q quote=%q suffix=%q", prefix, path, quote, suffix) + } +} + +func TestImportClaudeHooksCopiesImportedScriptWithMode(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + sourceRoot := filepath.Join(t.TempDir(), "hooks") + + scriptPath := filepath.Join(home, ".claude", "hooks", "audit", "scripts", "pre.sh") + writeFile(t, scriptPath, "#!/bin/sh\nexit 0\n") + if err := os.Chmod(scriptPath, 0o755); err != nil { + t.Fatalf("chmod script: %v", err) + } + writeFile(t, config.ClaudeSettingsPath(""), `{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + {"type": "command", "command": "`+filepath.ToSlash(scriptPath)+`"} + ] + } + ] + } +}`) + + if _, err := Import(sourceRoot, ImportOptions{From: "claude", All: true}); err != nil { + t.Fatalf("Import() error = %v", err) + } + + imported := filepath.Join(sourceRoot, "audit", "scripts", "pre.sh") + info, err := os.Stat(imported) + if err != nil { + t.Fatalf("stat imported script: %v", err) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("expected executable mode preserved, got %o", info.Mode().Perm()) + } +} + func writeHookBundle(t *testing.T, root, yamlText string) { t.Helper() if err := os.MkdirAll(root, 0o755); err != nil { diff --git a/internal/plugins/plugins.go b/internal/plugins/plugins.go index c1d2b377..8c3db406 100644 --- a/internal/plugins/plugins.go +++ b/internal/plugins/plugins.go @@ -659,32 +659,62 @@ func resolvePluginRef[T any](ref string, installed map[string][]T, ecosystem str func discoverCodexInstalledPlugins() (map[string][]string, error) { refs := readCodexConfiguredPluginRefs() cacheBase := config.CodexPluginCacheBase() - pattern := filepath.Join(cacheBase, "*", "*", "*") - matches, _ := filepath.Glob(pattern) out := map[string][]string{} - for _, dir := range matches { - if !dirExists(dir) || !fileExists(filepath.Join(dir, ".codex-plugin", "plugin.json")) { + providers, err := os.ReadDir(cacheBase) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + for _, providerEntry := range providers { + if !providerEntry.IsDir() { continue } - provider := filepath.Base(filepath.Dir(filepath.Dir(dir))) - name := filepath.Base(filepath.Dir(dir)) - if filepath.Base(dir) == "local" { - name = filepath.Base(filepath.Dir(dir)) - provider = "skillshare" + provider := providerEntry.Name() + names, nameErr := os.ReadDir(filepath.Join(cacheBase, provider)) + if nameErr != nil { + continue + } + for _, nameEntry := range names { + if !nameEntry.IsDir() { + continue + } + name := nameEntry.Name() + hashes, hashErr := os.ReadDir(filepath.Join(cacheBase, provider, name)) + if hashErr != nil { + continue + } + for _, hashEntry := range hashes { + if !hashEntry.IsDir() { + continue + } + dir := filepath.Join(cacheBase, provider, name, hashEntry.Name()) + if !fileExists(filepath.Join(dir, ".codex-plugin", "plugin.json")) { + continue + } + refProvider := provider + if hashEntry.Name() == "local" { + refProvider = "skillshare" + } + ref := name + "@" + refProvider + out[ref] = append(out[ref], dir) + } } - ref := name + "@" + provider - out[ref] = append(out[ref], dir) } for _, ref := range refs { if _, ok := out[ref]; ok { continue } name, provider := splitPluginRef(ref) - pattern := filepath.Join(cacheBase, provider, name, "*") - candidates, _ := filepath.Glob(pattern) + candidates, readErr := os.ReadDir(filepath.Join(cacheBase, provider, name)) + if readErr != nil { + continue + } for _, candidate := range candidates { - if dirExists(candidate) && fileExists(filepath.Join(candidate, ".codex-plugin", "plugin.json")) { - out[ref] = append(out[ref], candidate) + if !candidate.IsDir() { + continue + } + dir := filepath.Join(cacheBase, provider, name, candidate.Name()) + if fileExists(filepath.Join(dir, ".codex-plugin", "plugin.json")) { + out[ref] = append(out[ref], dir) } } } diff --git a/internal/plugins/plugins_test.go b/internal/plugins/plugins_test.go index 8ccbeef5..ab3c016b 100644 --- a/internal/plugins/plugins_test.go +++ b/internal/plugins/plugins_test.go @@ -167,6 +167,32 @@ enabled = false } } +func TestDiscoverCodexInstalledPluginsWalksProviderNameHashLayout(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + writeFile(t, config.CodexConfigPath(), ` +[plugins.'demo@provider-a'] # existing quoted ref +enabled = true +`) + providerA := filepath.Join(config.CodexPluginCacheBase(), "provider-a", "demo", "hash-a") + local := filepath.Join(config.CodexPluginCacheBase(), "skillshare", "demo", "local") + writeFile(t, filepath.Join(providerA, ".codex-plugin", "plugin.json"), `{"name":"demo","version":"1.0.0"}`) + writeFile(t, filepath.Join(local, ".codex-plugin", "plugin.json"), `{"name":"demo","version":"1.1.0"}`) + writeFile(t, filepath.Join(config.CodexPluginCacheBase(), "provider-a", "demo", "hash-a", "deep", "ignored", ".codex-plugin", "plugin.json"), `{"name":"demo","version":"bad"}`) + + installed, err := discoverCodexInstalledPlugins() + if err != nil { + t.Fatalf("discoverCodexInstalledPlugins() error = %v", err) + } + if got := installed["demo@provider-a"]; len(got) != 1 || got[0] != providerA { + t.Fatalf("unexpected provider-a entries: %+v", installed) + } + if got := installed["demo@skillshare"]; len(got) != 1 || got[0] != local { + t.Fatalf("unexpected skillshare entries: %+v", installed) + } +} + func TestSyncBundleClaudeUsesInstalledMetadataForUpdate(t *testing.T) { home := t.TempDir() binDir := filepath.Join(t.TempDir(), "bin") diff --git a/internal/tooling/fs.go b/internal/tooling/fs.go index f7f853eb..ec336cdb 100644 --- a/internal/tooling/fs.go +++ b/internal/tooling/fs.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "os" "path/filepath" "sort" @@ -12,27 +13,12 @@ import ( // CopyDir recursively copies src into dst, skipping .git directories. func CopyDir(src, dst string) error { - return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - rel, err := filepath.Rel(src, path) - if err != nil { - return err - } - target := filepath.Join(dst, rel) - if info.IsDir() { - if info.Name() == ".git" { - return filepath.SkipDir - } - return os.MkdirAll(target, info.Mode()) - } - return copyFile(path, target, info.Mode()) - }) + return walkCopyDir(src, dst, false) } -func copyFile(src, dst string, mode os.FileMode) error { - if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { +// CopyFile streams a single file from src to dst and preserves mode. +func CopyFile(src, dst string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } in, err := os.Open(src) @@ -47,8 +33,10 @@ func copyFile(src, dst string, mode os.FileMode) error { } defer out.Close() - _, err = io.Copy(out, in) - return err + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() } // ReplaceDir atomically replaces dst with a fresh copy of src. @@ -62,7 +50,11 @@ func ReplaceDir(src, dst string) error { // MergeDir recursively copies src into dst without removing dst first. // When a file/dir type conflicts, the destination path is replaced. func MergeDir(src, dst string) error { - return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + return walkCopyDir(src, dst, true) +} + +func walkCopyDir(src, dst string, merge bool) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } @@ -74,23 +66,35 @@ func MergeDir(src, dst string) error { return os.MkdirAll(dst, 0o755) } target := filepath.Join(dst, rel) - if info.IsDir() { - if info.Name() == ".git" { + if d.IsDir() { + if d.Name() == ".git" { return filepath.SkipDir } - if existing, statErr := os.Stat(target); statErr == nil && !existing.IsDir() { - if err := os.Remove(target); err != nil { - return err + info, err := d.Info() + if err != nil { + return err + } + if merge { + if existing, statErr := os.Stat(target); statErr == nil && !existing.IsDir() { + if err := os.Remove(target); err != nil { + return err + } } } return os.MkdirAll(target, info.Mode()) } - if existing, statErr := os.Stat(target); statErr == nil && existing.IsDir() { - if err := os.RemoveAll(target); err != nil { - return err + info, err := d.Info() + if err != nil { + return err + } + if merge { + if existing, statErr := os.Stat(target); statErr == nil && existing.IsDir() { + if err := os.RemoveAll(target); err != nil { + return err + } } } - return copyFile(path, target, info.Mode()) + return CopyFile(path, target, info.Mode()) }) } @@ -125,19 +129,22 @@ func ReadJSON(path string, dst any) error { // EnsureManagedTableEntry adds or updates a simple TOML boolean key in a table. func EnsureManagedTableEntry(content, header, key string, value bool) string { lines := strings.Split(content, "\n") - sectionLine := "[" + header + "]" valueLine := fmt.Sprintf("%s = %t", key, value) + wantPath, ok := parseTOMLHeaderPath("[" + header + "]") + if !ok { + return content + } for i := 0; i < len(lines); i++ { - if strings.TrimSpace(lines[i]) != sectionLine { + path, isHeader := parseTOMLHeaderPath(lines[i]) + if !isHeader || !tomlPathEqual(path, wantPath) { continue } for j := i + 1; j < len(lines); j++ { - trimmed := strings.TrimSpace(lines[j]) - if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + if _, nextHeader := parseTOMLHeaderPath(lines[j]); nextHeader { lines = append(lines[:j], append([]string{valueLine}, lines[j:]...)...) return strings.Join(lines, "\n") } - if strings.HasPrefix(trimmed, key+" =") { + if tomlLineDefinesKey(lines[j], key) { lines[j] = valueLine return strings.Join(lines, "\n") } @@ -151,7 +158,7 @@ func EnsureManagedTableEntry(content, header, key string, value bool) string { if content != "" { content += "\n" } - return content + sectionLine + "\n" + valueLine + "\n" + return content + "[" + header + "]\n" + valueLine + "\n" } // EnsureManagedTOMLBool adds or updates a boolean key in a TOML table path. @@ -160,18 +167,22 @@ func EnsureManagedTOMLBool(content string, tablePath []string, key string, value sectionLine := "[" + strings.Join(tablePath, ".") + "]" valueLine := fmt.Sprintf("%s = %t", key, value) lines := strings.Split(content, "\n") + wantPath, ok := parseTOMLHeaderPath(sectionLine) + if !ok { + return content + } for i := 0; i < len(lines); i++ { - if strings.TrimSpace(lines[i]) != sectionLine { + path, isHeader := parseTOMLHeaderPath(lines[i]) + if !isHeader || !tomlPathEqual(path, wantPath) { continue } insertAt := len(lines) for j := i + 1; j < len(lines); j++ { - trimmed := strings.TrimSpace(lines[j]) - if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + if _, nextHeader := parseTOMLHeaderPath(lines[j]); nextHeader { insertAt = j break } - if strings.HasPrefix(trimmed, key+" =") { + if tomlLineDefinesKey(lines[j], key) { lines[j] = valueLine return strings.Join(lines, "\n") } @@ -188,6 +199,114 @@ func EnsureManagedTOMLBool(content string, tablePath []string, key string, value return content + sectionLine + "\n" + valueLine + "\n" } +func parseTOMLHeaderPath(line string) ([]string, bool) { + trimmed := strings.TrimSpace(stripTOMLComment(line)) + if !strings.HasPrefix(trimmed, "[") || !strings.HasSuffix(trimmed, "]") { + return nil, false + } + body := strings.TrimSpace(trimmed[1 : len(trimmed)-1]) + if body == "" { + return nil, false + } + parts := splitTOMLHeaderParts(body) + if len(parts) == 0 { + return nil, false + } + return parts, true +} + +func splitTOMLHeaderParts(body string) []string { + var parts []string + var current strings.Builder + var quote rune + escaped := false + for _, r := range body { + switch { + case escaped: + current.WriteRune(r) + escaped = false + case quote != 0: + if quote == '"' && r == '\\' { + escaped = true + continue + } + if r == quote { + quote = 0 + continue + } + current.WriteRune(r) + case r == '"' || r == '\'': + quote = r + case r == '.': + parts = append(parts, strings.TrimSpace(current.String())) + current.Reset() + default: + current.WriteRune(r) + } + } + if escaped || quote != 0 { + return nil + } + parts = append(parts, strings.TrimSpace(current.String())) + for _, part := range parts { + if part == "" { + return nil + } + } + return parts +} + +func tomlPathEqual(left, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func tomlLineDefinesKey(line, key string) bool { + trimmed := strings.TrimSpace(stripTOMLComment(line)) + if !strings.HasPrefix(trimmed, key) { + return false + } + rest := strings.TrimSpace(strings.TrimPrefix(trimmed, key)) + return strings.HasPrefix(rest, "=") +} + +func stripTOMLComment(line string) string { + var out strings.Builder + var quote rune + escaped := false + for _, r := range line { + switch { + case escaped: + out.WriteRune(r) + escaped = false + case quote != 0: + out.WriteRune(r) + if quote == '"' && r == '\\' { + escaped = true + continue + } + if r == quote { + quote = 0 + } + case r == '"' || r == '\'': + quote = r + out.WriteRune(r) + case r == '#': + return out.String() + default: + out.WriteRune(r) + } + } + return out.String() +} + // ManagedJSONMapMerge rewrites a top-level object key containing event arrays. // Unmanaged entries are kept, managed entries are dropped when shouldRemove returns true, // and replacement entries are appended in sorted key order for stable output. diff --git a/internal/tooling/fs_test.go b/internal/tooling/fs_test.go new file mode 100644 index 00000000..6a80c3f3 --- /dev/null +++ b/internal/tooling/fs_test.go @@ -0,0 +1,34 @@ +package tooling + +import ( + "strings" + "testing" +) + +func TestEnsureManagedTOMLBoolMatchesQuotedHeaderWithComment(t *testing.T) { + initial := ` +[plugins.'demo@skillshare'] # existing plugin +enabled = false +` + got := EnsureManagedTOMLBool(initial, []string{"plugins", `"demo@skillshare"`}, "enabled", true) + if strings.Count(got, "enabled = true") != 1 { + t.Fatalf("expected one updated enabled line, got:\n%s", got) + } + if strings.Contains(got, `[plugins."demo@skillshare"]`+"\nenabled = true\n\n[plugins.'demo@skillshare']") { + t.Fatalf("expected existing table to be updated instead of duplicated:\n%s", got) + } +} + +func TestEnsureManagedTOMLBoolMatchesWhitespaceWrappedHeader(t *testing.T) { + initial := ` + [ features ] # keep comment +codex_hooks = false +` + got := EnsureManagedTOMLBool(initial, []string{"features"}, "codex_hooks", true) + if strings.Count(got, "codex_hooks = true") != 1 { + t.Fatalf("expected updated codex_hooks line, got:\n%s", got) + } + if strings.Count(got, "[features]") > 1 || strings.Count(got, "[ features ]") > 1 { + t.Fatalf("expected a single features table, got:\n%s", got) + } +}