@softspark/ai-toolkit 4.32.0 → 4.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ Versioning follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## v4.32.1 - Plugin removal leaves nothing behind (2026-09-04)
11
+
12
+ Both fixes come from running the post-release SOPs on v4.32.0 in an isolated
13
+ sandbox; both defects predate that release (identical on v4.31.0).
14
+
15
+ ### Fixed
16
+
17
+ - **`plugin remove` deletes the hooks and scripts a pack installed.** The
18
+ Claude and Codex install paths copied `hooks/plugin-<pack>-*` and
19
+ `plugin-scripts/<pack>/*` into `~/.softspark/ai-toolkit` but never recorded
20
+ them in `shared_asset_ownership`, so removal found no owner and preserved
21
+ every file as "untracked": each pack left 4-5 files behind. Install now
22
+ records sha256/mode/inode for what it copied; removal deletes only entries
23
+ this editor consumes that no other editor still uses and that are unchanged
24
+ on disk, preserves an edited file with a `WARN preserved changed plugin
25
+ asset` line, and drops the empty `plugin-scripts/<pack>/` directory.
26
+ `tests/test_plugin_asset_removal.bats` covers install, zero-residue removal,
27
+ user-edited preservation, two-editor sharing, and reinstall.
28
+ - **Five skill scripts answer `--help`.** `doc-inventory.py`,
29
+ `dependency-graph.py`, `migration-status.py`, `refactor-scan.py`, and
30
+ `complexity.py` treated `--help` as a path and returned a JSON error.
31
+ `tests/test_skill_scripts_help.bats` now probes every documented
32
+ `${CLAUDE_SKILL_DIR}` invocation with `--help` (exit 0, no traceback), the
33
+ same check as post-release SOP Phase 4b. The probe uses `perl alarm` as its
34
+ hang guard, not GNU `timeout`, which the macOS CI runner does not have.
35
+ - **`pre_deploy_check.py` no longer crashes on Linux.** It probed for docker
36
+ by running the shell builtin `command -v` through `subprocess`, which only
37
+ works on macOS (a `/usr/bin/command` binary exists there); on Linux it raised
38
+ `FileNotFoundError` before any check ran. Now `shutil.which`. Caught by the
39
+ new `--help` probe on the Ubuntu CI runner, which is the point of that test.
40
+ - **Test count.** Bats increased from 1966 to 1972.
41
+
10
42
  ## v4.32.0 - Context budget: scoped rules, scoped language skills, doctor budget checks (2026-09-04)
11
43
 
12
44
  ### Added
package/README.md CHANGED
@@ -6,30 +6,25 @@
6
6
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
7
7
  [![Skills](https://img.shields.io/badge/skills-114-brightgreen)](app/skills/)
8
8
  [![Agents](https://img.shields.io/badge/agents-44-blue)](app/agents/)
9
- [![Tests](https://img.shields.io/badge/tests-1966%20passing-success)](tests/)
10
-
11
- ## What's New in v4.32.0
12
-
13
- **v4.32.0** cuts what every session pays for before you type, and adds the
14
- checks that keep it cut:
15
-
16
- - Common rules are path-scoped from their source: `testing` and `performance`
17
- load only for matching files, and the project `.claude/CLAUDE.md` index says
18
- which rules are always-on instead of claiming lazy loading for all of them.
19
- - Language knowledge skills follow your projects: `install --language-skills
20
- detected` (default) turns off `<lang>-rules`/`<lang>-patterns` skills for
21
- languages no registered project uses, reversibly, and restores them when a
22
- project brings the language back.
23
- - `ai-toolkit doctor` gains a Context Budget check (est. resident tokens,
24
- zero-use skills) and a Permission Rules check (over-broad `permissions.allow`
25
- wildcards). Both read-only.
26
- - One strict frontmatter parser replaces twelve private copies; `validate.py`
27
- now rejects descriptions over 1024 characters or unquoted ones containing
28
- `: `. Every shipped Markdown file parses under the strict grammar.
29
- - Team-only git conventions moved to a `git-team` rule that ships with
30
- `--profile strict`; `quality-gate.sh` lints with ruff only when the project
31
- configured ruff; advisory Stop hooks run in the background. Test count:
32
- 1931 -> 1966 bats + 348 pytest.
9
+ [![Tests](https://img.shields.io/badge/tests-1972%20passing-success)](tests/)
10
+
11
+ ## What's New in v4.32.1
12
+
13
+ **v4.32.1** fixes two defects the post-release SOPs found on v4.32.0 (both
14
+ older than that release):
15
+
16
+ - `ai-toolkit plugin remove` now deletes the hooks and scripts a pack
17
+ installed under `~/.softspark/ai-toolkit`. Install records what it copied;
18
+ removal deletes only unchanged, unshared files and names anything it
19
+ preserves. Every pack used to leave 4-5 files behind.
20
+ - Five skill scripts (`doc-inventory`, `dependency-graph`, `migration-status`,
21
+ `refactor-scan`, `complexity`) answer `--help` instead of treating it as a
22
+ path; a new test probes every documented skill script with `--help`.
23
+ - Still in this train from v4.32.0: path-scoped common rules, language skills
24
+ scoped to your registered projects (`--language-skills`), `doctor` Context
25
+ Budget and Permission Rules checks, one strict frontmatter parser, and the
26
+ `git-team` rule for `--profile strict`. Test count: 1966 -> 1972 bats +
27
+ 348 pytest.
33
28
 
34
29
  See [CHANGELOG.md](CHANGELOG.md) for full history.
35
30
 
@@ -3,7 +3,7 @@
3
3
  "name": "ai-toolkit",
4
4
  "displayName": "AI Toolkit",
5
5
  "description": "Professional-grade engineering skills, agents, rules, and lifecycle guardrails for Claude Code, Claude Chat, and Cowork.",
6
- "version": "4.32.0",
6
+ "version": "4.32.1",
7
7
  "author": {
8
8
  "name": "SoftSpark",
9
9
  "url": "https://github.com/softspark"
@@ -125,6 +125,9 @@ def _summary(root: Path, all_files: list[Path]) -> tuple[int, int]:
125
125
 
126
126
  def main() -> None:
127
127
  """Entry point: analyse directory and print text report to stdout."""
128
+ if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
129
+ print("Usage: complexity.py [directory]\n\nReport per-file code complexity for directory (default: cwd).")
130
+ return
128
131
  root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd().resolve()
129
132
  now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
130
133
 
@@ -16,6 +16,7 @@ Usage::
16
16
  from __future__ import annotations
17
17
 
18
18
  import json
19
+ import shutil
19
20
  import subprocess
20
21
  import sys
21
22
  from datetime import datetime, timezone
@@ -50,12 +51,15 @@ def _git_branch() -> str:
50
51
 
51
52
 
52
53
  def _docker_available() -> bool:
53
- """Return True if the ``docker`` command is on PATH."""
54
- result = _run(["command", "-v", "docker"])
55
- # Fallback: just try docker --version
56
- if result.returncode != 0:
57
- result = _run(["docker", "--version"])
58
- return result.returncode == 0
54
+ """Return True if the ``docker`` command is on PATH.
55
+
56
+ ``command -v`` is a shell builtin; running it through ``subprocess`` only
57
+ worked on macOS, which ships a ``/usr/bin/command`` binary. On Linux it
58
+ raised ``FileNotFoundError`` before any check ran (v4.32.1).
59
+ """
60
+ if shutil.which("docker") is None:
61
+ return False
62
+ return _run(["docker", "--version"]).returncode == 0
59
63
 
60
64
 
61
65
  def _docker_services_running() -> bool:
@@ -155,6 +159,9 @@ def _run_checks(env: str) -> _CheckCollector:
155
159
 
156
160
  def main() -> None:
157
161
  """Entry point: run checks and print JSON result to stdout."""
162
+ if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
163
+ print("Usage: pre_deploy_check.py [environment]\n\nRun pre-deployment checks for environment (default: staging) and print JSON.")
164
+ return
158
165
  env = sys.argv[1] if len(sys.argv) > 1 else "staging"
159
166
  branch = _git_branch()
160
167
  collector = _run_checks(env)
@@ -130,6 +130,9 @@ def get_mtime(filepath: str) -> float:
130
130
 
131
131
 
132
132
  def main():
133
+ if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
134
+ print("Usage: doc-inventory.py [directory]\n\nInventory documentation files under directory (default: cwd) as JSON.")
135
+ return
133
136
  root = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.getcwd()
134
137
  if not os.path.isdir(root):
135
138
  print(json.dumps({"error": f"Not a directory: {root}"}))
@@ -159,6 +159,9 @@ def generate_mermaid(target_name: str, imports: list, imported_by: list) -> str:
159
159
 
160
160
 
161
161
  def main():
162
+ if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
163
+ print("Usage: dependency-graph.py <file_or_directory>\n\nPrint the import/dependency graph of a file or directory as JSON.")
164
+ return
162
165
  if len(sys.argv) < 2:
163
166
  print(json.dumps({"error": "Usage: dependency-graph.py <file_or_directory>"}))
164
167
  sys.exit(1)
@@ -182,6 +182,9 @@ def detect(project_dir: Path) -> dict[str, object]:
182
182
 
183
183
  def main() -> None:
184
184
  """Entry point: detect migration tool and print JSON result to stdout."""
185
+ if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
186
+ print("Usage: migration-status.py [project_dir]\n\nDetect the migration tool in project_dir (default: cwd) and report its status as JSON.")
187
+ return
185
188
  project_dir = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd()
186
189
  if not project_dir.is_dir():
187
190
  print(json.dumps({"error": f"Not a directory: {project_dir}"}))
@@ -154,6 +154,9 @@ def analyze_file(filepath: str, project_root: str) -> dict:
154
154
 
155
155
 
156
156
  def main():
157
+ if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
158
+ print("Usage: refactor-scan.py <file_or_directory>\n\nScan a file or directory for refactoring candidates and print JSON.")
159
+ return
157
160
  if len(sys.argv) < 2:
158
161
  print(json.dumps({"error": "Usage: refactor-scan.py <file_or_directory>"}))
159
162
  sys.exit(1)
@@ -3,9 +3,9 @@ title: "Plugin Pack Conventions"
3
3
  category: reference
4
4
  service: ai-toolkit
5
5
  tags: [plugins, plugin-packs, conventions, manifests, hooks, policy-packs]
6
- version: "1.3.0"
6
+ version: "1.4.0"
7
7
  created: "2026-03-28"
8
- last_updated: "2026-08-31"
8
+ last_updated: "2026-09-04"
9
9
  description: "Conventions for experimental ai-toolkit plugin packs, policy packs, hook packs, and plugin-creator scaffolding across supported editors."
10
10
  ---
11
11
 
@@ -167,7 +167,7 @@ ai-toolkit plugin status --editor all # show installed packs with r
167
167
  1. **Claude Code target**: strips plugin hook entries from `~/.claude/settings.json` and removes plugin-local rule sections from `~/.claude/CLAUDE.md`
168
168
  2. **Codex target**: strips only command handlers carrying the exact `AI_TOOLKIT_HOOK_OWNER=ai-toolkit-plugin-<pack>` marker from `$CODEX_HOME/hooks.json`, removes owned `$CODEX_HOME/ai-toolkit-hooks/plugin-<pack>-*` assets, and removes the pack's marker-bounded sections from `$CODEX_HOME/AGENTS.md`
169
169
  3. **Cursor/Gemini rules**: removes only exact content recorded in `rule_ownership`; foreign or user-modified files/sections are preserved with a warning
170
- 4. **Claude/shared assets** (`~/.softspark/ai-toolkit/hooks/plugin-*`, `plugin-scripts/<pack>/`) are removed only when no remaining runtime still uses that pack
170
+ 4. **Claude/shared assets** (`~/.softspark/ai-toolkit/hooks/plugin-*`, `plugin-scripts/<pack>/`) are removed only when no remaining runtime still uses that pack, and only when the file on disk is byte-, mode- and inode-identical to what install recorded in `shared_asset_ownership`; a file the user edited is preserved and named in a `WARN preserved changed plugin asset` line. Before v4.32.1 the Claude and Codex install paths never recorded that ownership, so removal preserved every hook and script as "untracked" and each pack left 4-5 files behind (found by the post-release SOP on v4.32.0)
171
171
  5. **Updates** `plugins.json` state per runtime
172
172
  6. **Leaves** core agents/skills untouched (they belong to the base install)
173
173
  7. **Leaves** plugin data intact (e.g. `memory.db` — use `clean` to prune)
package/llms-full.txt CHANGED
@@ -15855,9 +15855,9 @@ title: "Plugin Pack Conventions"
15855
15855
  category: reference
15856
15856
  service: ai-toolkit
15857
15857
  tags: [plugins, plugin-packs, conventions, manifests, hooks, policy-packs]
15858
- version: "1.3.0"
15858
+ version: "1.4.0"
15859
15859
  created: "2026-03-28"
15860
- last_updated: "2026-08-31"
15860
+ last_updated: "2026-09-04"
15861
15861
  description: "Conventions for experimental ai-toolkit plugin packs, policy packs, hook packs, and plugin-creator scaffolding across supported editors."
15862
15862
  ---
15863
15863
 
@@ -16019,7 +16019,7 @@ ai-toolkit plugin status --editor all # show installed packs with r
16019
16019
  1. **Claude Code target**: strips plugin hook entries from `~/.claude/settings.json` and removes plugin-local rule sections from `~/.claude/CLAUDE.md`
16020
16020
  2. **Codex target**: strips only command handlers carrying the exact `AI_TOOLKIT_HOOK_OWNER=ai-toolkit-plugin-<pack>` marker from `$CODEX_HOME/hooks.json`, removes owned `$CODEX_HOME/ai-toolkit-hooks/plugin-<pack>-*` assets, and removes the pack's marker-bounded sections from `$CODEX_HOME/AGENTS.md`
16021
16021
  3. **Cursor/Gemini rules**: removes only exact content recorded in `rule_ownership`; foreign or user-modified files/sections are preserved with a warning
16022
- 4. **Claude/shared assets** (`~/.softspark/ai-toolkit/hooks/plugin-*`, `plugin-scripts/<pack>/`) are removed only when no remaining runtime still uses that pack
16022
+ 4. **Claude/shared assets** (`~/.softspark/ai-toolkit/hooks/plugin-*`, `plugin-scripts/<pack>/`) are removed only when no remaining runtime still uses that pack, and only when the file on disk is byte-, mode- and inode-identical to what install recorded in `shared_asset_ownership`; a file the user edited is preserved and named in a `WARN preserved changed plugin asset` line. Before v4.32.1 the Claude and Codex install paths never recorded that ownership, so removal preserved every hook and script as "untracked" and each pack left 4-5 files behind (found by the post-release SOP on v4.32.0)
16023
16023
  5. **Updates** `plugins.json` state per runtime
16024
16024
  6. **Leaves** core agents/skills untouched (they belong to the base install)
16025
16025
  7. **Leaves** plugin data intact (e.g. `memory.db` — use `clean` to prune)
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.32.0",
2
+ "version": "4.32.1",
3
3
  "components": {
4
4
  "agents": {
5
5
  "description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softspark/ai-toolkit",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "AI coding toolkit: 109 skills, 44 agents, 14 developer-tool integrations, recoverable native tool-output filtering, Claude Chat/Cowork export, safety constitution, SARIF audit, and signed npm provenance.",
5
5
  "keywords": [
6
6
  "claude",
package/scripts/plugin.py CHANGED
@@ -2006,6 +2006,16 @@ def _apply_asset_install(
2006
2006
  "inode": version.inode,
2007
2007
  }
2008
2008
  print(f" Installed plugin {spec.kind}: {spec.path.name}")
2009
+ return _merge_asset_ownership(previous_ownership, name, editor, produced_entries)
2010
+
2011
+
2012
+ def _merge_asset_ownership(
2013
+ previous_ownership: dict | None,
2014
+ name: str,
2015
+ editor: str,
2016
+ produced_entries: dict[str, dict],
2017
+ ) -> dict:
2018
+ """Fold one editor's freshly written assets into the pack's ownership record."""
2009
2019
  entries = _asset_entries(previous_ownership, name)
2010
2020
  entries.update(produced_entries)
2011
2021
  consumers = _shared_asset_consumers(previous_ownership, name)
@@ -2022,6 +2032,37 @@ def _apply_asset_install(
2022
2032
  }
2023
2033
 
2024
2034
 
2035
+ def _ownership_from_copied_assets(
2036
+ name: str,
2037
+ editor: str,
2038
+ pack_dir: Path,
2039
+ hook_specs: list[dict],
2040
+ previous_ownership: dict | None,
2041
+ ) -> dict:
2042
+ """Record what the non-transactional Claude/Codex install just copied.
2043
+
2044
+ ``_copy_plugin_hook_scripts`` and ``_copy_plugin_scripts`` write the same
2045
+ paths ``_prepare_asset_specs`` describes but never recorded them, so
2046
+ removal had nothing to verify against and preserved every file as
2047
+ "untracked" (v4.32.1). Reading the versions back from disk after the copy
2048
+ gives removal the same sha256/mode/inode contract the JSON runtimes get.
2049
+ """
2050
+ produced: dict[str, dict] = {}
2051
+ for spec in _prepare_asset_specs(name, pack_dir, hook_specs):
2052
+ version = _read_file_version(spec.path)
2053
+ if version is None:
2054
+ continue
2055
+ produced[spec.key] = {
2056
+ "path": str(spec.path),
2057
+ "kind": spec.kind,
2058
+ "sha256": hashlib.sha256(version.content).hexdigest(),
2059
+ "mode": version.mode,
2060
+ "device": version.device,
2061
+ "inode": version.inode,
2062
+ }
2063
+ return _merge_asset_ownership(previous_ownership, name, editor, produced)
2064
+
2065
+
2025
2066
  def _preflight_asset_removal(
2026
2067
  transaction: PluginFileTransaction,
2027
2068
  ownership: dict | None,
@@ -2075,6 +2116,74 @@ def _apply_asset_removal(
2075
2116
  print(f" WARN preserved changed or user-owned plugin asset: {path}")
2076
2117
 
2077
2118
 
2119
+ def _remove_owned_plugin_assets(state: dict | None, name: str, editor: str) -> None:
2120
+ """Delete the hook and script files this pack installed for ``editor``.
2121
+
2122
+ Claude and Codex removal never consulted ``shared_asset_ownership``: the
2123
+ files were written on install, recorded, and then reported as "untracked"
2124
+ on remove because nothing looked the record up. Every pack left its hooks
2125
+ and ``plugin-scripts/<name>/`` behind (v4.32.1).
2126
+
2127
+ Same rules as the transactional runtimes: an entry is deleted only when
2128
+ this editor consumes it, no other editor still does, and the file on disk
2129
+ is byte- and inode-identical to what install recorded. Anything else
2130
+ (user edits, a file install never wrote, a symlink) is preserved and named.
2131
+ """
2132
+ ownership = _shared_asset_ownership_for(state or {}, name)
2133
+ entries = _asset_entries(ownership, name)
2134
+ consumers = _shared_asset_consumers(ownership, name)
2135
+ editor_keys = set(consumers.get(editor, ()))
2136
+ other_keys = {
2137
+ key
2138
+ for consumer, keys in consumers.items()
2139
+ if consumer != editor
2140
+ for key in keys
2141
+ }
2142
+ scripts_root = TOOLKIT_DATA_DIR / "plugin-scripts" / name
2143
+ hook_prefix = f"plugin-{name}-"
2144
+ handled: set[Path] = set()
2145
+
2146
+ for key, entry in entries.items():
2147
+ raw_path = entry.get("path")
2148
+ if not isinstance(raw_path, str):
2149
+ continue
2150
+ path = Path(raw_path)
2151
+ handled.add(path.absolute())
2152
+ if key not in editor_keys or key in other_keys:
2153
+ continue
2154
+ kind = entry.get("kind")
2155
+ if kind == "hook":
2156
+ safe = path.parent.absolute() == HOOKS_DIR.absolute() and path.name.startswith(hook_prefix)
2157
+ elif kind == "script":
2158
+ safe = path.parent.absolute() == scripts_root.absolute()
2159
+ else:
2160
+ safe = False
2161
+ if not safe:
2162
+ print(f" WARN refused to remove plugin asset outside its owned root: {path}")
2163
+ continue
2164
+ if path.is_symlink():
2165
+ print(f" WARN preserved symlinked plugin asset: {path}")
2166
+ continue
2167
+ version = _read_file_version(path)
2168
+ if version is None:
2169
+ continue
2170
+ if not _asset_entry_matches(path, version, entry):
2171
+ print(f" WARN preserved changed plugin asset: {path}")
2172
+ continue
2173
+ path.unlink()
2174
+ print(f" Removed plugin asset: {path.name}")
2175
+
2176
+ for hook in sorted(HOOKS_DIR.glob(f"{hook_prefix}*")):
2177
+ if hook.absolute() not in handled:
2178
+ print(f" WARN preserved untracked plugin hook: {hook}")
2179
+ if scripts_root.is_dir():
2180
+ for leftover in sorted(scripts_root.iterdir()):
2181
+ if leftover.absolute() not in handled:
2182
+ print(f" WARN preserved untracked plugin script: {leftover}")
2183
+ if not any(scripts_root.iterdir()):
2184
+ scripts_root.rmdir()
2185
+
2186
+
2078
2187
  def _state_after_removal(
2079
2188
  state: dict,
2080
2189
  editor: str,
@@ -2616,19 +2725,15 @@ def _remove_claude_pack_links(pack: dict, pack_dir: Path) -> None:
2616
2725
 
2617
2726
 
2618
2727
  def remove_pack_claude(
2619
- name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool
2728
+ name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool,
2729
+ state: dict | None = None,
2620
2730
  ) -> bool:
2621
2731
  hook_specs = _resolve_pack_hooks(pack, pack_dir)
2622
2732
  rule_specs = _resolve_pack_rules(pack, pack_dir)
2623
2733
  _remove_claude_pack_links(pack, pack_dir)
2624
2734
 
2625
2735
  if not keep_shared_assets:
2626
- for hook in HOOKS_DIR.glob(f"plugin-{name}-*"):
2627
- print(f" WARN preserved untracked plugin hook: {hook}")
2628
-
2629
- scripts_dir = TOOLKIT_DATA_DIR / "plugin-scripts" / name
2630
- if scripts_dir.is_dir():
2631
- print(f" WARN preserved untracked plugin scripts: {scripts_dir}")
2736
+ _remove_owned_plugin_assets(state, name, "claude")
2632
2737
 
2633
2738
  if any(not spec["is_core"] for spec in hook_specs):
2634
2739
  _strip_claude_hooks(name)
@@ -3000,7 +3105,8 @@ def install_pack_codex(name: str, pack: dict, pack_dir: Path) -> bool:
3000
3105
 
3001
3106
 
3002
3107
  def remove_pack_codex(
3003
- name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool
3108
+ name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool,
3109
+ state: dict | None = None,
3004
3110
  ) -> bool:
3005
3111
  _assert_safe_codex_surface()
3006
3112
  _strip_codex_hooks(name)
@@ -3016,14 +3122,10 @@ def remove_pack_codex(
3016
3122
  print(f" WARN preserved user-owned Codex hook: {hook.name}")
3017
3123
 
3018
3124
  if not keep_shared_assets:
3019
- # Clean paths used by releases before native Codex plugin assets moved
3020
- # under $CODEX_HOME. Claude still owns these when installed for both.
3021
- for hook in HOOKS_DIR.glob(f"plugin-{name}-*"):
3022
- print(f" WARN preserved untracked plugin hook: {hook}")
3023
-
3024
- scripts_dir = TOOLKIT_DATA_DIR / "plugin-scripts" / name
3025
- if scripts_dir.is_dir():
3026
- print(f" WARN preserved untracked plugin scripts: {scripts_dir}")
3125
+ # Paths used by releases before native Codex plugin assets moved under
3126
+ # $CODEX_HOME. Claude still owns these when installed for both, which
3127
+ # the ownership consumers encode; only Codex-consumed entries go.
3128
+ _remove_owned_plugin_assets(state, name, "codex")
3027
3129
 
3028
3130
  _remove_codex_rules(name)
3029
3131
  print(f" Done: removed {name} from codex")
@@ -3202,6 +3304,14 @@ def _install_pack_locked(
3202
3304
  if transaction_snapshots is not None:
3203
3305
  _restore_file_snapshots(transaction_snapshots)
3204
3306
  return False
3307
+ if editor in ("claude", "codex"):
3308
+ asset_ownership = _ownership_from_copied_assets(
3309
+ name,
3310
+ editor,
3311
+ pack_dir,
3312
+ _resolve_pack_hooks(pack, pack_dir),
3313
+ _shared_asset_ownership_for(state, name),
3314
+ )
3205
3315
 
3206
3316
  if hook_config_update is not None:
3207
3317
  if transaction_snapshots is not None:
@@ -3362,6 +3472,7 @@ def _remove_pack_locked(
3362
3472
  pack,
3363
3473
  pack_dir,
3364
3474
  keep_shared_assets=keep_shared_assets,
3475
+ state=state,
3365
3476
  )
3366
3477
  elif editor == "codex":
3367
3478
  ok = remove_pack_codex(
@@ -3369,6 +3480,7 @@ def _remove_pack_locked(
3369
3480
  pack,
3370
3481
  pack_dir,
3371
3482
  keep_shared_assets=keep_shared_assets,
3483
+ state=state,
3372
3484
  )
3373
3485
  elif editor in JSON_HOOK_RUNTIMES:
3374
3486
  ok = remove_pack_json_runtime(