@softspark/ai-toolkit 4.32.0 → 4.32.2

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,57 @@ Versioning follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## v4.32.2 - Plugin exports land where you run the command (2026-09-04)
11
+
12
+ ### Fixed
13
+
14
+ - **`claude-app export`, `codex-plugin export`, `antigravity-plugin export`
15
+ write to the shell's directory.** `bin/ai-toolkit.js` runs these three with
16
+ `cwd` inside the installed package so their asset paths resolve, which also
17
+ made every relative `--output`, and therefore every default
18
+ (`ai-toolkit-claude-app.zip`, `*-global-instructions.md`), appear next to
19
+ `node_modules/@softspark/ai-toolkit`. The wrapper now passes the user's
20
+ directory as `AI_TOOLKIT_USER_CWD` and `paths.user_path()` resolves relative
21
+ outputs against it; absolute paths and direct `python3 scripts/...` runs
22
+ are unchanged. Found the first time the v4.32.1 README instruction was
23
+ followed from `~/Downloads`.
24
+ - `.gitignore` ignores root-level `*.zip` and `*-global-instructions.md` so an
25
+ export run from the repository cannot be committed by accident.
26
+ - **Test count.** Bats increased from 1972 to 1974 (relative `--output` from
27
+ a foreign cwd for `claude-app` and `codex-plugin`).
28
+
29
+ ## v4.32.1 - Plugin removal leaves nothing behind (2026-09-04)
30
+
31
+ Both fixes come from running the post-release SOPs on v4.32.0 in an isolated
32
+ sandbox; both defects predate that release (identical on v4.31.0).
33
+
34
+ ### Fixed
35
+
36
+ - **`plugin remove` deletes the hooks and scripts a pack installed.** The
37
+ Claude and Codex install paths copied `hooks/plugin-<pack>-*` and
38
+ `plugin-scripts/<pack>/*` into `~/.softspark/ai-toolkit` but never recorded
39
+ them in `shared_asset_ownership`, so removal found no owner and preserved
40
+ every file as "untracked": each pack left 4-5 files behind. Install now
41
+ records sha256/mode/inode for what it copied; removal deletes only entries
42
+ this editor consumes that no other editor still uses and that are unchanged
43
+ on disk, preserves an edited file with a `WARN preserved changed plugin
44
+ asset` line, and drops the empty `plugin-scripts/<pack>/` directory.
45
+ `tests/test_plugin_asset_removal.bats` covers install, zero-residue removal,
46
+ user-edited preservation, two-editor sharing, and reinstall.
47
+ - **Five skill scripts answer `--help`.** `doc-inventory.py`,
48
+ `dependency-graph.py`, `migration-status.py`, `refactor-scan.py`, and
49
+ `complexity.py` treated `--help` as a path and returned a JSON error.
50
+ `tests/test_skill_scripts_help.bats` now probes every documented
51
+ `${CLAUDE_SKILL_DIR}` invocation with `--help` (exit 0, no traceback), the
52
+ same check as post-release SOP Phase 4b. The probe uses `perl alarm` as its
53
+ hang guard, not GNU `timeout`, which the macOS CI runner does not have.
54
+ - **`pre_deploy_check.py` no longer crashes on Linux.** It probed for docker
55
+ by running the shell builtin `command -v` through `subprocess`, which only
56
+ works on macOS (a `/usr/bin/command` binary exists there); on Linux it raised
57
+ `FileNotFoundError` before any check ran. Now `shutil.which`. Caught by the
58
+ new `--help` probe on the Ubuntu CI runner, which is the point of that test.
59
+ - **Test count.** Bats increased from 1966 to 1972.
60
+
10
61
  ## v4.32.0 - Context budget: scoped rules, scoped language skills, doctor budget checks (2026-09-04)
11
62
 
12
63
  ### 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-1974%20passing-success)](tests/)
10
+
11
+ ## What's New in v4.32.2
12
+
13
+ **v4.32.2** makes plugin exports land where you run the command:
14
+
15
+ - `ai-toolkit claude-app export`, `codex-plugin export`, and
16
+ `antigravity-plugin export` write their ZIP (and the sibling
17
+ `*-global-instructions.md`) to the directory you typed the command in. They
18
+ used to appear inside `node_modules/@softspark/ai-toolkit` because the CLI
19
+ runs them with its own working directory; the wrapper now hands the shell's
20
+ directory to the exporters. Absolute `--output` paths are unchanged.
21
+ - Root-level `*.zip` and `*-global-instructions.md` are gitignored so an
22
+ export run from the repository is never committed.
23
+ - Still in this train: `plugin remove` leaves nothing behind, every skill
24
+ script answers `--help` (v4.32.1); path-scoped common rules, project-scoped
25
+ language skills, `doctor` context-budget and permission checks, one strict
26
+ frontmatter parser, `git-team` for `--profile strict` (v4.32.0). Test
27
+ count: 1972 -> 1974 bats + 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.2",
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)
package/bin/ai-toolkit.js CHANGED
@@ -222,10 +222,14 @@ function runGenerator(scriptName, extraArgs = []) {
222
222
  */
223
223
  function run(script, args = [], opts = {}) {
224
224
  requirePython();
225
+ // Commands flagged toolkitCwd run with cwd inside the installed package so
226
+ // their relative asset paths resolve; the user's own directory travels in
227
+ // AI_TOOLKIT_USER_CWD so a relative --output still lands where they typed
228
+ // the command, not inside node_modules (v4.32.2).
225
229
  const result = spawnSync('python3', [script, ...args], {
226
230
  stdio: 'inherit',
227
231
  cwd: opts.cwd || CWD,
228
- env: { ...process.env },
232
+ env: { ...process.env, AI_TOOLKIT_USER_CWD: CWD },
229
233
  });
230
234
  if (result.status !== 0) {
231
235
  process.exit(result.status || 1);
@@ -30,7 +30,7 @@ Usage: ai-toolkit <command> [options]
30
30
  | `doctor` | Diagnose install health, hooks, quick-win assets, artifact drift, context budget (est. resident tokens of the skill/agent listings and user rules, plus skills with zero recorded use; read-only, prints the `skillOverrides` key to paste), and permission rules (`permissions.allow` wildcards on interpreters, task runners, package installs, `gh api`, `curl`, `git fetch`, destructive commands; warns only, never edits) |
31
31
  | `doctor --fix` | Auto-repair broken symlinks, missing hooks, stale artifacts |
32
32
  | `eject [dir]` | Export standalone config (no symlinks, no toolkit dependency) |
33
- | `claude-app export [--output FILE] [--no-custom-rules] [--verify]` | Build an uploadable Claude Chat/Desktop/Cowork plugin ZIP and global-instructions file |
33
+ | `claude-app export [--output FILE] [--no-custom-rules] [--verify]` | Writes to the directory you run the command in (relative `--output` and the default `ai-toolkit-claude-app.zip` + `*-global-instructions.md`; before v4.32.2 they landed inside `node_modules/@softspark/ai-toolkit`). Build an uploadable Claude Chat/Desktop/Cowork plugin ZIP and global-instructions file |
34
34
  | `claude-app verify` | Validate a clean staged plugin with structural checks and the official Claude plugin validator |
35
35
  | `codex-plugin export [--output FILE]` | Build a deterministic native Codex plugin ZIP with skills and self-contained hooks |
36
36
  | `codex-plugin verify` | Validate a clean native Codex plugin stage without installing it or changing user configuration |
@@ -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
@@ -10482,7 +10482,7 @@ Usage: ai-toolkit <command> [options]
10482
10482
  | `doctor` | Diagnose install health, hooks, quick-win assets, artifact drift, context budget (est. resident tokens of the skill/agent listings and user rules, plus skills with zero recorded use; read-only, prints the `skillOverrides` key to paste), and permission rules (`permissions.allow` wildcards on interpreters, task runners, package installs, `gh api`, `curl`, `git fetch`, destructive commands; warns only, never edits) |
10483
10483
  | `doctor --fix` | Auto-repair broken symlinks, missing hooks, stale artifacts |
10484
10484
  | `eject [dir]` | Export standalone config (no symlinks, no toolkit dependency) |
10485
- | `claude-app export [--output FILE] [--no-custom-rules] [--verify]` | Build an uploadable Claude Chat/Desktop/Cowork plugin ZIP and global-instructions file |
10485
+ | `claude-app export [--output FILE] [--no-custom-rules] [--verify]` | Writes to the directory you run the command in (relative `--output` and the default `ai-toolkit-claude-app.zip` + `*-global-instructions.md`; before v4.32.2 they landed inside `node_modules/@softspark/ai-toolkit`). Build an uploadable Claude Chat/Desktop/Cowork plugin ZIP and global-instructions file |
10486
10486
  | `claude-app verify` | Validate a clean staged plugin with structural checks and the official Claude plugin validator |
10487
10487
  | `codex-plugin export [--output FILE]` | Build a deterministic native Codex plugin ZIP with skills and self-contained hooks |
10488
10488
  | `codex-plugin verify` | Validate a clean native Codex plugin stage without installing it or changing user configuration |
@@ -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.2",
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.2",
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",
@@ -22,6 +22,7 @@ from typing import Any
22
22
  sys.path.insert(0, str(Path(__file__).resolve().parent))
23
23
 
24
24
  from dir_rules_shared import STANDARD_RULES
25
+ from paths import user_path
25
26
  from emission import agents_dir
26
27
  from generate_antigravity_agents import render_agent
27
28
  from generate_antigravity_hooks import (
@@ -550,7 +551,7 @@ def main(argv: list[str] | None = None) -> int:
550
551
  or args.output_path
551
552
  or "ai-toolkit-antigravity-plugin.zip"
552
553
  )
553
- path = export(output)
554
+ path = export(user_path(output))
554
555
  print(f"Created: {path}")
555
556
  print(
556
557
  "Install under .agents/plugins/ai-toolkit/ (workspace), "
@@ -29,6 +29,7 @@ from pathlib import Path
29
29
 
30
30
  sys.path.insert(0, str(Path(__file__).resolve().parent))
31
31
  from frontmatter import split_frontmatter # noqa: E402
32
+ from paths import user_path # noqa: E402
32
33
 
33
34
 
34
35
  TOOLKIT_DIR = Path(__file__).resolve().parent.parent
@@ -347,7 +348,7 @@ def main(argv: list[str] | None = None) -> int:
347
348
  return 0 if verify_plugin() else 1
348
349
  if args.action == "export":
349
350
  return 0 if export_plugin(
350
- Path(args.output),
351
+ user_path(args.output),
351
352
  include_custom_rules=not args.no_custom_rules,
352
353
  verify=args.verify,
353
354
  ) else 1
@@ -26,6 +26,7 @@ from typing import Any
26
26
  sys.path.insert(0, str(Path(__file__).resolve().parent))
27
27
 
28
28
  from codex_skill_adapter import build_codex_skill_text
29
+ from paths import user_path
29
30
  from generate_codex_hooks import (
30
31
  CODEX_HOOKS,
31
32
  _asset_names,
@@ -754,7 +755,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
754
755
  def main(argv: list[str] | None = None) -> int:
755
756
  args = parse_args(argv)
756
757
  if args.action == "export":
757
- return 0 if export_plugin(Path(args.output)) else 1
758
+ return 0 if export_plugin(user_path(args.output)) else 1
758
759
  if args.action == "verify":
759
760
  return 0 if verify_plugin() else 1
760
761
  return 2
package/scripts/paths.py CHANGED
@@ -55,3 +55,21 @@ LEGACY_PROJECT_LOCK = ".ai-toolkit.lock.json"
55
55
 
56
56
  # Base config filename (published in npm packages — unchanged)
57
57
  BASE_CONFIG_FILENAME = "ai-toolkit.config.json"
58
+
59
+
60
+ def user_path(raw: "str | os.PathLike[str]") -> Path:
61
+ """Resolve a user-supplied path against the directory the user ran the CLI in.
62
+
63
+ ``bin/ai-toolkit.js`` runs some commands (``claude-app``, ``codex-plugin``,
64
+ ``antigravity-plugin``) with ``cwd`` inside the installed package so their
65
+ relative asset paths resolve, and passes the shell's own directory as
66
+ ``AI_TOOLKIT_USER_CWD``. Before v4.32.2 a relative ``--output`` (including
67
+ every default) was written next to ``node_modules/@softspark/ai-toolkit``.
68
+ Absolute paths pass through; direct ``python3 scripts/...`` runs, where the
69
+ variable is unset, resolve against the real cwd as before.
70
+ """
71
+ path = Path(raw).expanduser()
72
+ if path.is_absolute():
73
+ return path
74
+ base = os.environ.get("AI_TOOLKIT_USER_CWD", "").strip()
75
+ return (Path(base) if base else Path.cwd()) / path
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(