claude-dev-env 8.21.0 → 8.22.0
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/.agents/skills/sync-dev-env/SKILL.md +51 -0
- package/.agents/skills/sync-dev-env/scripts/link_plugin_skills.py +211 -0
- package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/__init__.py +0 -0
- package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/__init__.py +1 -0
- package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/constants.py +26 -0
- package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/test_constants.py +19 -0
- package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/test___init__.py +5 -0
- package/.agents/skills/sync-dev-env/scripts/test_link_plugin_skills.py +142 -0
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/merge_settings_defaults.test.mjs +43 -0
- package/hooks/blocking/edit_marker_gate.py +74 -0
- package/hooks/blocking/reply_length_gate.py +119 -0
- package/hooks/blocking/test_edit_marker_gate.py +110 -0
- package/hooks/blocking/test_reply_length_gate.py +193 -0
- package/hooks/hooks.json +20 -0
- package/hooks/hooks_constants/edit_marker_gate_constants.py +18 -0
- package/hooks/hooks_constants/reply_length_gate_constants.py +28 -0
- package/hooks/hooks_constants/test_edit_marker_gate_constants.py +9 -0
- package/hooks/hooks_constants/test_reply_length_gate_constants.py +15 -0
- package/package.json +1 -1
- package/rules/asd-ste100-language.md +1 -0
- package/rules/ci-owns-the-gate.md +28 -0
- package/rules/memory-stores-durable-facts.md +57 -0
- package/rules/review-closure-is-a-check.md +1 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +4 -0
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +13 -0
- package/scripts/dev_env_scripts_constants/shared_tree_constants.py +4 -0
- package/scripts/invoke_code_review.py +4 -1
- package/scripts/local_report_cli.py +50 -53
- package/scripts/policy_lint/adapter_configuration.py +8 -0
- package/scripts/policy_lint/config/constants.py +2 -0
- package/scripts/resolve_worker_spawn.py +4 -1
- package/scripts/review_closure_model.py +16 -3
- package/scripts/shared_tree_paths.py +27 -0
- package/scripts/test_resolve_worker_spawn.py +28 -0
- package/scripts/test_shared_tree_paths.py +27 -0
- package/scripts/tests/test_adapter_configuration.py +16 -0
- package/scripts/tests/test_local_report_publisher_cli.py +54 -0
- package/scripts/tests/test_review_closure.py +1 -0
- package/scripts/tests/test_review_closure_model.py +70 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sync-dev-env
|
|
3
|
+
description: Install or update claude-dev-env in a running session, link the skills of every enabled plugin so a running session loads them, and schedule a daily re-sync at 6:00 AM local by default. Use when the user asks to sync claude-dev-env into a session, to load the skills of a plugin without a restart, or to set up the daily sync.
|
|
4
|
+
argument-hint: "[HH:MM] [IANA time zone]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /sync-dev-env
|
|
8
|
+
|
|
9
|
+
An always-on session starts once, so a SessionStart hook runs once and never again. Claude Code reads plugins only at session start, and a running session has no plugin reload. The skills directory is read live, so one symlink per skill makes each skill of a plugin load at once.
|
|
10
|
+
|
|
11
|
+
## 1. Install or update the package
|
|
12
|
+
|
|
13
|
+
Run `npx --yes claude-dev-env@latest --update --target ~/.claude`.
|
|
14
|
+
|
|
15
|
+
The installer leaves symlinks in the skills directory in place.
|
|
16
|
+
|
|
17
|
+
## 2. Link the plugin skills
|
|
18
|
+
|
|
19
|
+
Run `python3 "${CLAUDE_SKILL_DIR}/scripts/link_plugin_skills.py"`.
|
|
20
|
+
|
|
21
|
+
The script reads `enabledPlugins` from `~/.claude/settings.json` and the newest install of each plugin from `~/.claude/plugins/installed_plugins.json`. It links each plugin skill by its bare name. A link it owns points inside `~/.claude/plugins/cache`. On each run it re-points an owned link at the current plugin version and removes an owned link whose skill is gone or whose plugin is disabled. A name some other skill already holds is skipped and reported.
|
|
22
|
+
|
|
23
|
+
Tell the user the summary line. Report each skipped name, because a plugin skill that refers to a skipped sibling by `../<name>` reaches the other skill.
|
|
24
|
+
|
|
25
|
+
## 3. Schedule the daily sync
|
|
26
|
+
|
|
27
|
+
Skip this step when `list_triggers` already shows a routine named `Daily dev env sync` bound to this session.
|
|
28
|
+
|
|
29
|
+
The time defaults to 6:00 AM in the user's time zone. Take the time and the IANA zone from the arguments. With no zone given and none known, ask for it.
|
|
30
|
+
|
|
31
|
+
In a Claude Code cloud session, call `create_trigger`:
|
|
32
|
+
|
|
33
|
+
- `name`: `Daily dev env sync`
|
|
34
|
+
- `cron_expression`: `CRON_TZ=<zone> <minute> <hour> * * *`, with the minute jittered a few minutes early per the tool's guidance
|
|
35
|
+
- `prompt`: `/sync-dev-env`
|
|
36
|
+
- `initiation`: `human_request`
|
|
37
|
+
|
|
38
|
+
Bind the routine to the session whose container needs the sync. Called from that session, leave `persistent_session_id` unset. Called from another session, set `persistent_session_id` to the session that needs the sync. Never set `create_new_session_on_fire`, because a fresh session gets its own container and syncs nothing the running session reads.
|
|
39
|
+
|
|
40
|
+
Then call `list_triggers` and tell the user the routine name and its `next_run_at` in their time zone. The schedule counts as armed only once that listing shows it.
|
|
41
|
+
|
|
42
|
+
In a local Claude Code session, `CronCreate` jobs expire after 7 days. Use `/loop` or an operating system scheduler for a local daily sync.
|
|
43
|
+
|
|
44
|
+
## Layout
|
|
45
|
+
|
|
46
|
+
| File | Role |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `SKILL.md` | This flow: install, link, schedule |
|
|
49
|
+
| `scripts/link_plugin_skills.py` | Converges the skills directory on one link per enabled plugin skill |
|
|
50
|
+
| `scripts/test_link_plugin_skills.py` | Behavior tests for link, skip, re-point, remove, and a second run |
|
|
51
|
+
| `scripts/sync_dev_env_constants/config/constants.py` | Plugin file paths, settings keys, outcome names, report lines |
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Link every enabled plugin's skills into the user skills directory.
|
|
3
|
+
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
~/.claude/skills/tdd -> ~/.claude/plugins/cache/<market>/<plugin>/<version>/skills/tdd
|
|
7
|
+
ok: no entry named tdd -> link created
|
|
8
|
+
ok: owned link at an old version -> link re-pointed
|
|
9
|
+
ok: owned link, skill now gone -> link removed
|
|
10
|
+
flag: a user skill named tdd -> left in place, reported as skipped
|
|
11
|
+
|
|
12
|
+
A link is owned when it points inside the plugin cache.
|
|
13
|
+
A running session reads the skills directory live, so the linked skills load at once.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from collections import Counter
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
scripts_directory = str(Path(__file__).resolve().parent)
|
|
26
|
+
if scripts_directory not in sys.path:
|
|
27
|
+
sys.path.insert(0, scripts_directory)
|
|
28
|
+
|
|
29
|
+
from sync_dev_env_constants.config.constants import (
|
|
30
|
+
CREATED_REPORT,
|
|
31
|
+
DEFAULT_CLAUDE_HOME,
|
|
32
|
+
ENABLED_PLUGINS_KEY,
|
|
33
|
+
INSTALL_PATH_KEY,
|
|
34
|
+
INSTALLED_PLUGINS_RELATIVE_PATH,
|
|
35
|
+
KEPT_OUTCOME,
|
|
36
|
+
LAST_UPDATED_KEY,
|
|
37
|
+
LINKED_OUTCOME,
|
|
38
|
+
PLUGIN_CACHE_RELATIVE_PATH,
|
|
39
|
+
PLUGINS_KEY,
|
|
40
|
+
REMOVED_OUTCOME,
|
|
41
|
+
REMOVED_REPORT,
|
|
42
|
+
REPOINTED_OUTCOME,
|
|
43
|
+
REPOINTED_REPORT,
|
|
44
|
+
SETTINGS_FILE_NAME,
|
|
45
|
+
SKILL_MANIFEST_FILE_NAME,
|
|
46
|
+
SKILLS_DIRECTORY_NAME,
|
|
47
|
+
SKIPPED_OUTCOME,
|
|
48
|
+
SKIPPED_REPORT,
|
|
49
|
+
SUMMARY_REPORT,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_json_file(from_path: Path) -> dict:
|
|
54
|
+
"""Read one JSON settings file.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
from_path: The JSON file to read.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The parsed object, or an empty dict when the file is absent.
|
|
61
|
+
"""
|
|
62
|
+
if not from_path.is_file():
|
|
63
|
+
return {}
|
|
64
|
+
return json.loads(from_path.read_text(encoding="utf-8"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def enabled_install_paths(claude_home: Path) -> list[Path]:
|
|
68
|
+
"""List the newest install directory of every plugin the user settings enable.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
claude_home: The Claude Code home directory.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
One install directory per enabled plugin that has an install record.
|
|
75
|
+
"""
|
|
76
|
+
enabled_by_plugin = read_json_file(claude_home / SETTINGS_FILE_NAME).get(ENABLED_PLUGINS_KEY, {})
|
|
77
|
+
records_by_plugin = read_json_file(claude_home / INSTALLED_PLUGINS_RELATIVE_PATH).get(PLUGINS_KEY, {})
|
|
78
|
+
all_install_paths = []
|
|
79
|
+
for each_plugin, each_record_list in records_by_plugin.items():
|
|
80
|
+
if enabled_by_plugin.get(each_plugin) is not True or not each_record_list:
|
|
81
|
+
continue
|
|
82
|
+
newest_record = max(each_record_list, key=lambda each_record: each_record.get(LAST_UPDATED_KEY, ""))
|
|
83
|
+
all_install_paths.append(Path(newest_record[INSTALL_PATH_KEY]))
|
|
84
|
+
return all_install_paths
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def skill_directories_of(install_path: Path) -> list[Path]:
|
|
88
|
+
"""List the skill directories one plugin install ships.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
install_path: The plugin install directory.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Each subdirectory of the install's skills directory that holds a SKILL.md.
|
|
95
|
+
"""
|
|
96
|
+
skills_root = install_path / SKILLS_DIRECTORY_NAME
|
|
97
|
+
if not skills_root.is_dir():
|
|
98
|
+
return []
|
|
99
|
+
return [
|
|
100
|
+
each_directory
|
|
101
|
+
for each_directory in sorted(skills_root.iterdir())
|
|
102
|
+
if (each_directory / SKILL_MANIFEST_FILE_NAME).is_file()
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def desired_skill_targets(claude_home: Path) -> dict[str, Path]:
|
|
107
|
+
"""Map each enabled plugin skill name to the directory its link points at.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
claude_home: The Claude Code home directory.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The target directory by skill name. The first plugin to ship a name keeps it.
|
|
114
|
+
"""
|
|
115
|
+
target_by_name: dict[str, Path] = {}
|
|
116
|
+
for each_install_path in enabled_install_paths(claude_home):
|
|
117
|
+
for each_directory in skill_directories_of(each_install_path):
|
|
118
|
+
target_by_name.setdefault(each_directory.name, each_directory)
|
|
119
|
+
return target_by_name
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def is_owned_link(entry: Path, plugin_cache: Path) -> bool:
|
|
123
|
+
"""Report whether an entry is a symlink that points inside the plugin cache.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
entry: One entry of the skills directory.
|
|
127
|
+
plugin_cache: The resolved plugin cache directory.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
True when the entry is a symlink whose target lies under the plugin cache.
|
|
131
|
+
"""
|
|
132
|
+
if not entry.is_symlink():
|
|
133
|
+
return False
|
|
134
|
+
link_target = Path(os.path.normpath(entry.parent / os.readlink(entry)))
|
|
135
|
+
return link_target.is_relative_to(plugin_cache)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def sync_links(claude_home: Path) -> Counter:
|
|
139
|
+
"""Converge the skills directory on one link per enabled plugin skill.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
claude_home: The Claude Code home directory.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
The count of each outcome name across every entry the run touched.
|
|
146
|
+
"""
|
|
147
|
+
skills_directory = Path(os.path.realpath(claude_home / SKILLS_DIRECTORY_NAME))
|
|
148
|
+
skills_directory.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
plugin_cache = Path(os.path.realpath(claude_home / PLUGIN_CACHE_RELATIVE_PATH))
|
|
150
|
+
target_by_name = desired_skill_targets(claude_home)
|
|
151
|
+
outcome_counts: Counter = Counter()
|
|
152
|
+
for each_entry in sorted(skills_directory.iterdir()):
|
|
153
|
+
if is_owned_link(each_entry, plugin_cache) and each_entry.name not in target_by_name:
|
|
154
|
+
each_entry.unlink()
|
|
155
|
+
print(REMOVED_REPORT.format(name=each_entry.name))
|
|
156
|
+
outcome_counts[REMOVED_OUTCOME] += 1
|
|
157
|
+
for each_name, each_target in sorted(target_by_name.items()):
|
|
158
|
+
outcome_counts[link_one_skill(skills_directory / each_name, each_target, plugin_cache)] += 1
|
|
159
|
+
return outcome_counts
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def link_one_skill(link_path: Path, target: Path, plugin_cache: Path) -> str:
|
|
163
|
+
"""Create or re-point one owned link, leaving any other entry of that name alone.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
link_path: Where the link belongs in the skills directory.
|
|
167
|
+
target: The plugin skill directory the link points at.
|
|
168
|
+
plugin_cache: The resolved plugin cache directory.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The outcome name: linked, repointed, kept, or skipped.
|
|
172
|
+
"""
|
|
173
|
+
if is_owned_link(link_path, plugin_cache):
|
|
174
|
+
if os.readlink(link_path) == str(target):
|
|
175
|
+
return KEPT_OUTCOME
|
|
176
|
+
link_path.unlink()
|
|
177
|
+
link_path.symlink_to(target, target_is_directory=True)
|
|
178
|
+
print(REPOINTED_REPORT.format(name=link_path.name, target=target))
|
|
179
|
+
return REPOINTED_OUTCOME
|
|
180
|
+
if link_path.exists() or link_path.is_symlink():
|
|
181
|
+
print(SKIPPED_REPORT.format(name=link_path.name))
|
|
182
|
+
return SKIPPED_OUTCOME
|
|
183
|
+
link_path.symlink_to(target, target_is_directory=True)
|
|
184
|
+
print(CREATED_REPORT.format(name=link_path.name, target=target))
|
|
185
|
+
return LINKED_OUTCOME
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def main() -> int:
|
|
189
|
+
"""Parse the Claude home from the command line, sync the links, and print a summary.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
0 after printing the summary line.
|
|
193
|
+
"""
|
|
194
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
195
|
+
parser.add_argument("--claude-home", type=Path, default=DEFAULT_CLAUDE_HOME)
|
|
196
|
+
parsed_arguments = parser.parse_args()
|
|
197
|
+
outcome_counts = sync_links(parsed_arguments.claude_home)
|
|
198
|
+
print(
|
|
199
|
+
SUMMARY_REPORT.format(
|
|
200
|
+
linked=outcome_counts[LINKED_OUTCOME],
|
|
201
|
+
repointed=outcome_counts[REPOINTED_OUTCOME],
|
|
202
|
+
removed=outcome_counts[REMOVED_OUTCOME],
|
|
203
|
+
skipped=outcome_counts[SKIPPED_OUTCOME],
|
|
204
|
+
kept=outcome_counts[KEPT_OUTCOME],
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Config package for the link_plugin_skills constants."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Paths, file keys, and report lines for the link_plugin_skills script."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
DEFAULT_CLAUDE_HOME = Path.home() / ".claude"
|
|
6
|
+
INSTALLED_PLUGINS_RELATIVE_PATH = Path("plugins") / "installed_plugins.json"
|
|
7
|
+
PLUGIN_CACHE_RELATIVE_PATH = Path("plugins") / "cache"
|
|
8
|
+
SETTINGS_FILE_NAME = "settings.json"
|
|
9
|
+
SKILLS_DIRECTORY_NAME = "skills"
|
|
10
|
+
SKILL_MANIFEST_FILE_NAME = "SKILL.md"
|
|
11
|
+
ENABLED_PLUGINS_KEY = "enabledPlugins"
|
|
12
|
+
PLUGINS_KEY = "plugins"
|
|
13
|
+
INSTALL_PATH_KEY = "installPath"
|
|
14
|
+
LAST_UPDATED_KEY = "lastUpdated"
|
|
15
|
+
CREATED_REPORT = "linked {name} -> {target}"
|
|
16
|
+
REPOINTED_REPORT = "re-pointed {name} -> {target}"
|
|
17
|
+
REMOVED_REPORT = "removed {name} (its skill left the plugin or the plugin is off)"
|
|
18
|
+
SKIPPED_REPORT = (
|
|
19
|
+
"skipped {name}: a skill the plugin does not own already uses that name"
|
|
20
|
+
)
|
|
21
|
+
SUMMARY_REPORT = "{linked} linked, {repointed} re-pointed, {removed} removed, {skipped} skipped, {kept} unchanged"
|
|
22
|
+
LINKED_OUTCOME = "linked"
|
|
23
|
+
REPOINTED_OUTCOME = "repointed"
|
|
24
|
+
REMOVED_OUTCOME = "removed"
|
|
25
|
+
SKIPPED_OUTCOME = "skipped"
|
|
26
|
+
KEPT_OUTCOME = "kept"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from sync_dev_env_constants.config import constants
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_paths_match_the_claude_code_plugin_layout() -> None:
|
|
5
|
+
assert constants.INSTALLED_PLUGINS_RELATIVE_PATH.parts == (
|
|
6
|
+
"plugins",
|
|
7
|
+
"installed_plugins.json",
|
|
8
|
+
)
|
|
9
|
+
assert constants.PLUGIN_CACHE_RELATIVE_PATH.parts == ("plugins", "cache")
|
|
10
|
+
assert constants.DEFAULT_CLAUDE_HOME.name == ".claude"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_keys_match_the_claude_code_settings_files() -> None:
|
|
14
|
+
assert (
|
|
15
|
+
constants.ENABLED_PLUGINS_KEY,
|
|
16
|
+
constants.PLUGINS_KEY,
|
|
17
|
+
constants.INSTALL_PATH_KEY,
|
|
18
|
+
constants.LAST_UPDATED_KEY,
|
|
19
|
+
) == ("enabledPlugins", "plugins", "installPath", "lastUpdated")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
scripts_directory = str(Path(__file__).resolve().parent)
|
|
8
|
+
if scripts_directory not in sys.path:
|
|
9
|
+
sys.path.insert(0, scripts_directory)
|
|
10
|
+
|
|
11
|
+
import link_plugin_skills
|
|
12
|
+
|
|
13
|
+
PLUGIN_KEY = "kit@market"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def install_plugin(
|
|
17
|
+
claude_home: Path, version: str, all_skill_names: tuple[str, ...]
|
|
18
|
+
) -> Path:
|
|
19
|
+
install_path = claude_home / "plugins" / "cache" / "market" / "kit" / version
|
|
20
|
+
for each_name in all_skill_names:
|
|
21
|
+
skill_directory = install_path / "skills" / each_name
|
|
22
|
+
skill_directory.mkdir(parents=True)
|
|
23
|
+
(skill_directory / "SKILL.md").write_text("---\nname: x\n---\n")
|
|
24
|
+
record = {
|
|
25
|
+
"installPath": str(install_path),
|
|
26
|
+
"version": version,
|
|
27
|
+
"lastUpdated": version,
|
|
28
|
+
}
|
|
29
|
+
(claude_home / "plugins" / "installed_plugins.json").write_text(
|
|
30
|
+
json.dumps({"version": 2, "plugins": {PLUGIN_KEY: [record]}})
|
|
31
|
+
)
|
|
32
|
+
return install_path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def enable_plugin(claude_home: Path, is_enabled: bool) -> None:
|
|
36
|
+
(claude_home / "settings.json").write_text(
|
|
37
|
+
json.dumps({"enabledPlugins": {PLUGIN_KEY: is_enabled}})
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def claude_home(tmp_path: Path) -> Path:
|
|
43
|
+
home = tmp_path / ".claude"
|
|
44
|
+
(home / "plugins").mkdir(parents=True)
|
|
45
|
+
agents_skills = tmp_path / ".agents" / "skills"
|
|
46
|
+
agents_skills.mkdir(parents=True)
|
|
47
|
+
(home / "skills").symlink_to(agents_skills, target_is_directory=True)
|
|
48
|
+
enable_plugin(home, True)
|
|
49
|
+
return home
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_sync(claude_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
53
|
+
monkeypatch.setattr(
|
|
54
|
+
sys, "argv", ["link_plugin_skills.py", "--claude-home", str(claude_home)]
|
|
55
|
+
)
|
|
56
|
+
link_plugin_skills.main()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_should_link_each_plugin_skill_through_the_skills_pointer(
|
|
60
|
+
claude_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
61
|
+
) -> None:
|
|
62
|
+
install_path = install_plugin(claude_home, "1.0.0", ("tdd", "how"))
|
|
63
|
+
|
|
64
|
+
run_sync(claude_home, monkeypatch)
|
|
65
|
+
|
|
66
|
+
assert (claude_home / "skills" / "tdd").resolve() == install_path / "skills" / "tdd"
|
|
67
|
+
assert (claude_home / "skills" / "how" / "SKILL.md").is_file()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_should_leave_an_existing_skill_of_the_same_name_untouched(
|
|
71
|
+
claude_home: Path,
|
|
72
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
73
|
+
capsys: pytest.CaptureFixture[str],
|
|
74
|
+
) -> None:
|
|
75
|
+
install_plugin(claude_home, "1.0.0", ("tdd",))
|
|
76
|
+
own_skill = claude_home / "skills" / "tdd"
|
|
77
|
+
own_skill.mkdir()
|
|
78
|
+
(own_skill / "SKILL.md").write_text("mine")
|
|
79
|
+
|
|
80
|
+
run_sync(claude_home, monkeypatch)
|
|
81
|
+
|
|
82
|
+
assert not own_skill.is_symlink()
|
|
83
|
+
assert (own_skill / "SKILL.md").read_text() == "mine"
|
|
84
|
+
assert "skipped tdd" in capsys.readouterr().out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_should_repoint_links_and_drop_vanished_skills_when_the_version_changes(
|
|
88
|
+
claude_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
89
|
+
) -> None:
|
|
90
|
+
install_plugin(claude_home, "1.0.0", ("tdd", "old"))
|
|
91
|
+
run_sync(claude_home, monkeypatch)
|
|
92
|
+
new_install_path = install_plugin(claude_home, "2.0.0", ("tdd",))
|
|
93
|
+
|
|
94
|
+
run_sync(claude_home, monkeypatch)
|
|
95
|
+
|
|
96
|
+
assert (
|
|
97
|
+
claude_home / "skills" / "tdd"
|
|
98
|
+
).resolve() == new_install_path / "skills" / "tdd"
|
|
99
|
+
assert not (claude_home / "skills" / "old").is_symlink()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_should_remove_owned_links_when_the_plugin_is_disabled(
|
|
103
|
+
claude_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
104
|
+
) -> None:
|
|
105
|
+
install_plugin(claude_home, "1.0.0", ("tdd",))
|
|
106
|
+
run_sync(claude_home, monkeypatch)
|
|
107
|
+
enable_plugin(claude_home, False)
|
|
108
|
+
|
|
109
|
+
run_sync(claude_home, monkeypatch)
|
|
110
|
+
|
|
111
|
+
assert list((claude_home / "skills").iterdir()) == []
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_should_keep_a_user_link_that_points_outside_the_plugin_cache(
|
|
115
|
+
claude_home: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
116
|
+
) -> None:
|
|
117
|
+
install_plugin(claude_home, "1.0.0", ())
|
|
118
|
+
elsewhere = tmp_path / "elsewhere"
|
|
119
|
+
elsewhere.mkdir()
|
|
120
|
+
user_link = claude_home / "skills" / "mine"
|
|
121
|
+
user_link.symlink_to(elsewhere, target_is_directory=True)
|
|
122
|
+
|
|
123
|
+
run_sync(claude_home, monkeypatch)
|
|
124
|
+
|
|
125
|
+
assert user_link.resolve() == elsewhere
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_should_change_nothing_on_a_second_run(
|
|
129
|
+
claude_home: Path,
|
|
130
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
131
|
+
capsys: pytest.CaptureFixture[str],
|
|
132
|
+
) -> None:
|
|
133
|
+
install_plugin(claude_home, "1.0.0", ("tdd", "how"))
|
|
134
|
+
run_sync(claude_home, monkeypatch)
|
|
135
|
+
capsys.readouterr()
|
|
136
|
+
|
|
137
|
+
run_sync(claude_home, monkeypatch)
|
|
138
|
+
|
|
139
|
+
assert (
|
|
140
|
+
capsys.readouterr().out.strip()
|
|
141
|
+
== "0 linked, 0 re-pointed, 0 removed, 0 skipped, 2 unchanged"
|
|
142
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
mergeMissingSettingsDefaults,
|
|
6
|
+
settingsDefaultsFromPackageSettings,
|
|
7
|
+
} from './merge_settings_defaults.mjs';
|
|
8
|
+
|
|
9
|
+
test('settingsDefaultsFromPackageSettings excludes managed lists', () => {
|
|
10
|
+
const packageSettings = {
|
|
11
|
+
permissions: { allow: ['Read'] },
|
|
12
|
+
advisorModel: 'fable',
|
|
13
|
+
env: { EDITOR: 'code' },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
assert.deepEqual(settingsDefaultsFromPackageSettings(packageSettings), {
|
|
17
|
+
advisorModel: 'fable',
|
|
18
|
+
env: { EDITOR: 'code' },
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('settingsDefaultsFromPackageSettings ignores non-object sources', () => {
|
|
23
|
+
for (const packageSettings of [null, undefined, [], 'settings']) {
|
|
24
|
+
assert.deepEqual(settingsDefaultsFromPackageSettings(packageSettings), {});
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('mergeMissingSettingsDefaults adds absent keys and keeps present values', () => {
|
|
29
|
+
const targetSettings = { advisorModel: null, permissions: { allow: ['Read'] } };
|
|
30
|
+
|
|
31
|
+
assert.deepEqual(
|
|
32
|
+
mergeMissingSettingsDefaults(targetSettings, {
|
|
33
|
+
advisorModel: 'fable',
|
|
34
|
+
theme: 'dark',
|
|
35
|
+
}),
|
|
36
|
+
{ addedKeys: ['theme'] },
|
|
37
|
+
);
|
|
38
|
+
assert.deepEqual(targetSettings, {
|
|
39
|
+
advisorModel: null,
|
|
40
|
+
permissions: { allow: ['Read'] },
|
|
41
|
+
theme: 'dark',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook that keeps edit markers out of an edited chat message.
|
|
3
|
+
|
|
4
|
+
The gate reads the whole input of a tool whose name ends in
|
|
5
|
+
MESSAGE_EDIT_TOOL_SUFFIX, including the text and any replacement card. It
|
|
6
|
+
denies the call when the input holds strikethrough or an "[Edit:" note. The
|
|
7
|
+
reader of an edited message sees only its new text, so the model resends
|
|
8
|
+
that clean text.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
18
|
+
if hooks_root_directory not in sys.path:
|
|
19
|
+
sys.path.insert(0, hooks_root_directory)
|
|
20
|
+
|
|
21
|
+
from hooks_constants.edit_marker_gate_constants import (
|
|
22
|
+
ALLOW_EXIT_CODE,
|
|
23
|
+
BLOCK_EXIT_CODE,
|
|
24
|
+
EDIT_NOTE_MESSAGE,
|
|
25
|
+
EDIT_NOTE_PATTERN,
|
|
26
|
+
HOOK_EVENT_NAME,
|
|
27
|
+
MESSAGE_EDIT_TOOL_SUFFIX,
|
|
28
|
+
RETRY_INSTRUCTION,
|
|
29
|
+
STRIKETHROUGH_MESSAGE,
|
|
30
|
+
STRIKETHROUGH_PATTERN,
|
|
31
|
+
TOOL_INPUT_KEY,
|
|
32
|
+
TOOL_NAME_KEY,
|
|
33
|
+
)
|
|
34
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
35
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def edit_marker_violation(all_tool_input: dict[str, object]) -> str | None:
|
|
39
|
+
"""Return the deny reason for an edit that keeps old text, or None when clean."""
|
|
40
|
+
serialized_input = json.dumps(all_tool_input, ensure_ascii=False)
|
|
41
|
+
if STRIKETHROUGH_PATTERN.search(serialized_input):
|
|
42
|
+
return STRIKETHROUGH_MESSAGE
|
|
43
|
+
if EDIT_NOTE_PATTERN.search(serialized_input):
|
|
44
|
+
return EDIT_NOTE_MESSAGE
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main() -> int:
|
|
49
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
50
|
+
if hook_input is None:
|
|
51
|
+
return ALLOW_EXIT_CODE
|
|
52
|
+
tool_name = hook_input.get(TOOL_NAME_KEY)
|
|
53
|
+
if not isinstance(tool_name, str) or not tool_name.endswith(MESSAGE_EDIT_TOOL_SUFFIX):
|
|
54
|
+
return ALLOW_EXIT_CODE
|
|
55
|
+
tool_input = hook_input.get(TOOL_INPUT_KEY)
|
|
56
|
+
if not isinstance(tool_input, dict):
|
|
57
|
+
return ALLOW_EXIT_CODE
|
|
58
|
+
violation = edit_marker_violation(tool_input)
|
|
59
|
+
if violation is None:
|
|
60
|
+
return ALLOW_EXIT_CODE
|
|
61
|
+
block_reason = violation + RETRY_INSTRUCTION
|
|
62
|
+
log_hook_block(
|
|
63
|
+
Path(__file__).name,
|
|
64
|
+
HOOK_EVENT_NAME,
|
|
65
|
+
block_reason,
|
|
66
|
+
tool_name=tool_name,
|
|
67
|
+
offending_input_preview=str(tool_input),
|
|
68
|
+
)
|
|
69
|
+
sys.stderr.write(block_reason)
|
|
70
|
+
return BLOCK_EXIT_CODE
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
sys.exit(main())
|