/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

@@ -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)
+ }
+}