From a9928d842962f73a54a281fe31f7f54e1e53cbb0 Mon Sep 17 00:00:00 2001 From: Luke LaBonte Date: Tue, 26 May 2026 15:18:42 -0500 Subject: [PATCH 1/6] Make plugin hook helpers self-contained Copy shared common helpers into each plugin package so installed hooks do not depend on repo-local symlinks. Route XcodeBuildTools background hook work through a command wrapper instead of host-level async metadata, and add sync/check coverage. --- .claude/commands/update-plugin.md | 4 + CLAUDE.md | 12 +- MarvinOutputStyle/common | 1 - MarvinOutputStyle/common/detect_xcode_mcp.py | 59 +++++++ MarvinOutputStyle/common/hooks.json | 26 +++ MarvinOutputStyle/common/pre-tool-use.py | 112 +++++++++++++ MarvinOutputStyle/common/session-start.py | 109 +++++++++++++ MarvinOutputStyle/common/version_tracker.py | 162 +++++++++++++++++++ PluginBase/common | 1 - PluginBase/common/detect_xcode_mcp.py | 59 +++++++ PluginBase/common/hooks.json | 26 +++ PluginBase/common/pre-tool-use.py | 112 +++++++++++++ PluginBase/common/session-start.py | 109 +++++++++++++ PluginBase/common/version_tracker.py | 162 +++++++++++++++++++ README.md | 8 +- SwiftScaffolding/common | 1 - SwiftScaffolding/common/detect_xcode_mcp.py | 59 +++++++ SwiftScaffolding/common/hooks.json | 26 +++ SwiftScaffolding/common/pre-tool-use.py | 112 +++++++++++++ SwiftScaffolding/common/session-start.py | 109 +++++++++++++ SwiftScaffolding/common/version_tracker.py | 162 +++++++++++++++++++ XcodeBuildTools/README.md | 6 +- XcodeBuildTools/common | 1 - XcodeBuildTools/common/detect_xcode_mcp.py | 59 +++++++ XcodeBuildTools/common/hooks.json | 26 +++ XcodeBuildTools/common/pre-tool-use.py | 112 +++++++++++++ XcodeBuildTools/common/session-start.py | 109 +++++++++++++ XcodeBuildTools/common/version_tracker.py | 162 +++++++++++++++++++ XcodeBuildTools/hooks/approve-xcode-mcp.sh | 2 +- XcodeBuildTools/hooks/hooks.json | 6 +- XcodeBuildTools/hooks/lib/sandbox.sh | 4 +- XcodeBuildTools/hooks/run-background.sh | 32 ++++ XcodeBuildTools/hooks/write-env.sh | 5 +- iOSSimulator/common | 1 - iOSSimulator/common/detect_xcode_mcp.py | 59 +++++++ iOSSimulator/common/hooks.json | 26 +++ iOSSimulator/common/pre-tool-use.py | 112 +++++++++++++ iOSSimulator/common/session-start.py | 109 +++++++++++++ iOSSimulator/common/version_tracker.py | 162 +++++++++++++++++++ scripts/sync-plugin-common.py | 144 +++++++++++++++++ tests/test_repository_integrity.py | 84 ++++++++++ 41 files changed, 2627 insertions(+), 25 deletions(-) delete mode 120000 MarvinOutputStyle/common create mode 100755 MarvinOutputStyle/common/detect_xcode_mcp.py create mode 100644 MarvinOutputStyle/common/hooks.json create mode 100755 MarvinOutputStyle/common/pre-tool-use.py create mode 100755 MarvinOutputStyle/common/session-start.py create mode 100644 MarvinOutputStyle/common/version_tracker.py delete mode 120000 PluginBase/common create mode 100755 PluginBase/common/detect_xcode_mcp.py create mode 100644 PluginBase/common/hooks.json create mode 100755 PluginBase/common/pre-tool-use.py create mode 100755 PluginBase/common/session-start.py create mode 100644 PluginBase/common/version_tracker.py delete mode 120000 SwiftScaffolding/common create mode 100755 SwiftScaffolding/common/detect_xcode_mcp.py create mode 100644 SwiftScaffolding/common/hooks.json create mode 100755 SwiftScaffolding/common/pre-tool-use.py create mode 100755 SwiftScaffolding/common/session-start.py create mode 100644 SwiftScaffolding/common/version_tracker.py delete mode 120000 XcodeBuildTools/common create mode 100755 XcodeBuildTools/common/detect_xcode_mcp.py create mode 100644 XcodeBuildTools/common/hooks.json create mode 100755 XcodeBuildTools/common/pre-tool-use.py create mode 100755 XcodeBuildTools/common/session-start.py create mode 100644 XcodeBuildTools/common/version_tracker.py create mode 100755 XcodeBuildTools/hooks/run-background.sh delete mode 120000 iOSSimulator/common create mode 100755 iOSSimulator/common/detect_xcode_mcp.py create mode 100644 iOSSimulator/common/hooks.json create mode 100755 iOSSimulator/common/pre-tool-use.py create mode 100755 iOSSimulator/common/session-start.py create mode 100644 iOSSimulator/common/version_tracker.py create mode 100755 scripts/sync-plugin-common.py diff --git a/.claude/commands/update-plugin.md b/.claude/commands/update-plugin.md index 0b34a54..19f0804 100644 --- a/.claude/commands/update-plugin.md +++ b/.claude/commands/update-plugin.md @@ -14,6 +14,7 @@ if available; otherwise ask normally in chat. - Look at git changes - If there are no changes but the branch differs from remote then compare local with remote. - If there are changes in files in the `common` directory these affect every plugin so we need to update every plugin. + - If files in `common/` changed, run `scripts/sync-plugin-common.py` before editing versions so each plugin package has the current copied helpers. - If `common/hooks.json` was changed, you must propagate those changes to each plugin's `hooks/hooks.json` file while preserving the unique plugin name in each command (e.g., `session-start.py MarvinOutputStyle` stays unique per plugin). 2. **Identify the plugin to update** @@ -34,6 +35,8 @@ if available; otherwise ask normally in chat. - If the user created a new folder in the plugin's `skills` folder, add the folder name to the `skills` folder in `info.json`. If some folder was renamed on this folder rename it in the array as well. 6. **Verify the updates** + - Run `scripts/sync-plugin-common.py --check` + - Run `python3 -m unittest discover -s tests -v` - Show the user a summary of all changes made - List the files that were modified - Confirm the version numbers match across all files @@ -58,6 +61,7 @@ if available; otherwise ask normally in chat. - Ensure all JSON files remain valid after edits - The marketplace catalog and plugin manifest versions must match - The git tag version must also match (plugin.json version, marketplace.json version, and the `v{version}` portion of the tag are all the same string) +- `common/` is the canonical source for shared hook helpers. The installed plugin package must be self-contained, so copied files under each plugin's `common/` directory must be regenerated with `scripts/sync-plugin-common.py` rather than edited by hand. - Each plugin has its own `hooks/hooks.json` file with unique command identifiers (plugin name baked into commands). When updating hooks: - The hook structure comes from `common/hooks.json` as reference - Each plugin's hooks.json must include the plugin name in commands (e.g., `${CLAUDE_PLUGIN_ROOT}/common/session-start.py PluginName`) diff --git a/CLAUDE.md b/CLAUDE.md index f4aeba5..0186128 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,11 +89,13 @@ Users add the marketplace, then install plugins from it: ### Updating Plugins When updating a plugin: -1. Update version in plugin's `plugin.json` -2. Update version in marketplace catalog entry (`.claude-plugin/marketplace.json`) -3. Document changes in plugin's README.md and the `versions` array in the plugin's `info.json` -4. Commit and push the version bump -5. Tag the release (see "Tagging Plugin Releases" below) +1. If `common/` changed, run `scripts/sync-plugin-common.py` so every plugin package gets updated helper copies. +2. Update version in plugin's `plugin.json` +3. Update version in marketplace catalog entry (`.claude-plugin/marketplace.json`) +4. Document changes in plugin's README.md and the `versions` array in the plugin's `info.json` +5. Run `scripts/sync-plugin-common.py --check` and `python3 -m unittest discover -s tests -v` +6. Commit and push the version bump +7. Tag the release (see "Tagging Plugin Releases" below) ### Tagging Plugin Releases diff --git a/MarvinOutputStyle/common b/MarvinOutputStyle/common deleted file mode 120000 index 60d3b0a..0000000 --- a/MarvinOutputStyle/common +++ /dev/null @@ -1 +0,0 @@ -../common \ No newline at end of file diff --git a/MarvinOutputStyle/common/detect_xcode_mcp.py b/MarvinOutputStyle/common/detect_xcode_mcp.py new file mode 100755 index 0000000..9518457 --- /dev/null +++ b/MarvinOutputStyle/common/detect_xcode_mcp.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# Generated from common/detect_xcode_mcp.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/detect_xcode_mcp.py and rerun scripts/sync-plugin-common.py. +"""Detect whether Xcode's native MCP server is available.""" + +import subprocess + + +def detect_xcode_mcp() -> dict: + """Check if Xcode MCP bridge is installed and likely active. + + Returns dict with: + xcode_mcp_installed: xcrun --find mcpbridge succeeds + xcode_running: Xcode process is running + mcpbridge_running: mcpbridge process is running + xcode_mcp_likely: installed AND (xcode OR bridge running) + """ + result = { + "xcode_mcp_installed": False, + "xcode_running": False, + "mcpbridge_running": False, + "xcode_mcp_likely": False, + } + + # Check if mcpbridge binary exists (Xcode 26.3+) + try: + subprocess.run( + ["xcrun", "--find", "mcpbridge"], + capture_output=True, timeout=5 + ).returncode == 0 and result.update({"xcode_mcp_installed": True}) + except Exception: + pass + + if not result["xcode_mcp_installed"]: + return result + + # Check if Xcode is running + try: + if subprocess.run( + ["pgrep", "-x", "Xcode"], + capture_output=True, timeout=3 + ).returncode == 0: + result["xcode_running"] = True + except Exception: + pass + + # Check if mcpbridge is running + try: + if subprocess.run( + ["pgrep", "-f", "mcpbridge"], + capture_output=True, timeout=3 + ).returncode == 0: + result["mcpbridge_running"] = True + except Exception: + pass + + result["xcode_mcp_likely"] = ( + result["xcode_running"] or result["mcpbridge_running"] + ) + return result diff --git a/MarvinOutputStyle/common/hooks.json b/MarvinOutputStyle/common/hooks.json new file mode 100644 index 0000000..c67edb9 --- /dev/null +++ b/MarvinOutputStyle/common/hooks.json @@ -0,0 +1,26 @@ +{ + "$comment": "Generated from common/hooks.json by scripts/sync-plugin-common.py. Do not edit this copy; edit common/hooks.json and rerun scripts/sync-plugin-common.py.", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/common/session-start.py \"${CLAUDE_PLUGIN_ROOT}\"" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|Skill", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/common/pre-tool-use.py \"${CLAUDE_PLUGIN_ROOT}\"" + } + ] + } + ] + } +} diff --git a/MarvinOutputStyle/common/pre-tool-use.py b/MarvinOutputStyle/common/pre-tool-use.py new file mode 100755 index 0000000..163ddf6 --- /dev/null +++ b/MarvinOutputStyle/common/pre-tool-use.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# Generated from common/pre-tool-use.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/pre-tool-use.py and rerun scripts/sync-plugin-common.py. + +import sys +import json +import os +import re + +from version_tracker import load_json_file, save_json_file + +def allow(): + response = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow" + } + } + print(json.dumps(response)) + sys.exit(0) + +def deny(message: str): + response = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": message + } + } + print(json.dumps(response)) + sys.exit(0) + + +def main(): + try: + # Get plugin name from plugin.json + plugin_dir = os.environ.get('CLAUDE_PLUGIN_ROOT', '') + plugin_json_path = os.path.join(plugin_dir, ".claude-plugin", "plugin.json") + plugin_json = load_json_file(plugin_json_path) or {} + plugin_name = plugin_json.get('name', '') + + info_file = os.path.join(plugin_dir, "info.json") + info_data = load_json_file(info_file) or {} + skills = info_data.get('skills', []) + + # Read JSON from stdin + input_data = json.load(sys.stdin) + + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + command = tool_input.get("command", "") + + used_skill = tool_input.get("skill", "") + + if plugin_name and tool_name == "Skill" and (used_skill in skills or used_skill.startswith(f"{plugin_name}:")): + allow() + + if tool_name == "Bash" and f"{plugin_dir}/skills/" in command: + allow() + + # Check pre-tool-use rules from info.json + if tool_name == "Bash" and command: + rules = info_data.get('pre-tool-use-rules', []) + + for rule in rules: + rule_name = rule.get('name', '') + match_pattern = rule.get('match', '') + not_match_pattern = rule.get('not_match', '') + decision = rule.get('decision', 'deny') + message = rule.get('message', 'Command denied by rule') + + if not match_pattern or not re.search(match_pattern, command): + continue + + # If not_match is specified and matches, skip this rule + if not_match_pattern and re.search(not_match_pattern, command): + continue + + # Rule matched - apply decision + if decision == 'allow': + allow() + + if decision == 'deny': + deny(message) + + # decision == 'deny_once' - check session tracking + tmpdir = os.environ.get('TMPDIR', '/tmp') + session_file = os.path.join(tmpdir, f"{plugin_name}.json") + + session_data = load_json_file(session_file) or {} + session_id = input_data.get("session_id", "") + session_rules = session_data.get(session_id, []) + + if rule_name in session_rules: + # Already seen this rule in this session, allow + continue + + # First time seeing this rule in this session - deny and record + session_rules.append(rule_name) + session_data[session_id] = session_rules + save_json_file(session_file, session_data) + deny(message) + + # If conditions don't match, allow the tool to proceed (no output needed) + sys.exit(0) + + except Exception as e: + # On error, allow the tool to proceed (fail open) + sys.stderr.write(f"Hook error: {e}\n") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/MarvinOutputStyle/common/session-start.py b/MarvinOutputStyle/common/session-start.py new file mode 100755 index 0000000..6facb5b --- /dev/null +++ b/MarvinOutputStyle/common/session-start.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# Generated from common/session-start.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/session-start.py and rerun scripts/sync-plugin-common.py. + +import sys +import json +import os +import re + +from version_tracker import check_for_updates, load_json_file + + +def derive_config_filename(plugin_name: str) -> str: + """Derive config filename from plugin name by removing spaces and lowercasing first letter.""" + name_no_spaces = plugin_name.replace(" ", "") + if name_no_spaces: + return name_no_spaces[0].lower() + name_no_spaces[1:] + ".json" + return "plugin.json" + + +def main(): + try: + # Get the plugin directory from environment + plugin_dir = os.environ.get('CLAUDE_PLUGIN_ROOT', '') + if not plugin_dir: + sys.exit(0) + + # Load plugin name from plugin.json + plugin_json_path = os.path.join(plugin_dir, ".claude-plugin", "plugin.json") + plugin_json = load_json_file(plugin_json_path) or {} + plugin_name = plugin_json.get('name', 'Plugin') + + # Load welcome message from info.json + info_json_path = os.path.join(plugin_dir, "info.json") + info_json = load_json_file(info_json_path) or {} + welcome_message = info_json.get('welcomeMessage', '') + + config_filename = derive_config_filename(plugin_name) + md_file = os.path.join(plugin_dir, "session-start.md") + + # Read the markdown file + if os.path.exists(md_file): + with open(md_file, 'r', encoding='utf-8') as f: + additional_context = f.read() + else: + additional_context = "" + + # Detect Xcode MCP availability (fail-open: treat as unavailable on error) + xcode_mcp_likely = False + try: + from detect_xcode_mcp import detect_xcode_mcp + detection = detect_xcode_mcp() + xcode_mcp_likely = detection.get("xcode_mcp_likely", False) + except Exception: + pass + + # Process conditional blocks in markdown (no-op if markers absent) + if xcode_mcp_likely: + # Keep IF_XCODE_MCP content, remove IF_NO_XCODE_MCP content + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'.*?\n?', + '', additional_context, flags=re.DOTALL) + else: + # Keep IF_NO_XCODE_MCP content, remove IF_XCODE_MCP content + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'.*?\n?', + '', additional_context, flags=re.DOTALL) + + system_message = f"The {plugin_name} plugin is loaded and ready." + if xcode_mcp_likely: + system_message += " Xcode MCP server detected." + if welcome_message: + system_message += f" {welcome_message}" + + # Check for version updates + changelog, _ = check_for_updates( + plugin_dir=plugin_dir, + config_filename=config_filename, + plugin_name=plugin_name + ) + if changelog: + system_message += changelog + + # Output the JSON structure with the content from the markdown file + response = { + "systemMessage": system_message, + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": additional_context + } + } + + print(json.dumps(response)) + sys.exit(0) + + except Exception as e: + # On error, exit silently (fail open) + sys.stderr.write(f"Hook error: {e}\n") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/MarvinOutputStyle/common/version_tracker.py b/MarvinOutputStyle/common/version_tracker.py new file mode 100644 index 0000000..9a2d4d5 --- /dev/null +++ b/MarvinOutputStyle/common/version_tracker.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# Generated from common/version_tracker.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/version_tracker.py and rerun scripts/sync-plugin-common.py. +""" +Version tracking utilities for Claude Code-compatible plugins. + +Compares a project's last-seen version against a plugin's version history +and returns changelog information for any new versions. +""" + +import json +import os +import sys + + +def parse_version(version_str): + """Parse a version string like '0.1.0' into a tuple of integers.""" + if not version_str: + return (0, 0, 0) + try: + parts = version_str.split('.') + return tuple(int(p) for p in parts) + except (ValueError, AttributeError): + return (0, 0, 0) + + +def get_new_versions(versions_list, last_version): + """Get all versions newer than last_version, sorted oldest to newest.""" + last_parsed = parse_version(last_version) + new_versions = [] + for entry in versions_list: + v = entry.get('version', '') + if parse_version(v) > last_parsed: + new_versions.append(entry) + # Sort by version (oldest first so changes are listed chronologically) + new_versions.sort(key=lambda x: parse_version(x.get('version', ''))) + return new_versions + + +def load_json_file(file_path): + """Load a JSON file, returning empty dict/list on error.""" + if not os.path.exists(file_path): + return None + try: + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return None + + +def save_json_file(file_path, data): + """Save data to a JSON file, creating parent directories if needed.""" + try: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=4) + return True + except (IOError, OSError): + return False + + +def migrate_config_from_project_dir(config_filename, new_config_file): + """ + Migrate a config file from the old .claude/ project directory to the new + CLAUDE_PLUGIN_DATA location, if it exists and hasn't been migrated yet. + + Args: + config_filename: Name of the config file (e.g., "testPlugin.json") + new_config_file: Full path to the new config file location + """ + # Sanitize config_filename to prevent path traversal + if os.sep in config_filename or '/' in config_filename: + return + + if os.path.exists(new_config_file): + return + + project_path = os.environ.get('CLAUDE_PROJECT_DIR', '') + if not project_path: + return + + old_config_file = os.path.join(project_path, ".claude", config_filename) + if not os.path.exists(old_config_file): + return + + # Migrate: copy old config to new location, then remove old file + old_data = load_json_file(old_config_file) + if old_data is not None: + if save_json_file(new_config_file, old_data): + try: + os.remove(old_config_file) + except OSError as e: + print(f"Warning: could not remove old config file {old_config_file}: {e}", file=sys.stderr) + + +def check_for_updates(plugin_dir, config_filename, plugin_name=None): + """ + Check for plugin updates and return changelog if there are new versions. + + Args: + plugin_dir: Path to the plugin directory (containing info.json) + config_filename: Name of the config file to store in CLAUDE_PLUGIN_DATA (e.g., "testPlugin.json") + plugin_name: Optional name for the changelog header (defaults to "Plugin") + + Returns: + tuple: (changelog_text, updated) where changelog_text is the markdown + changelog (empty string if no updates) and updated is a boolean + indicating whether the config was updated + """ + data_dir = os.environ.get('CLAUDE_PLUGIN_DATA', '') + if not data_dir: + return "", False + + info_file = os.path.join(plugin_dir, "info.json") + if not os.path.exists(info_file): + return "", False + + config_file = os.path.join(data_dir, config_filename) + + # Migrate from old .claude/ project directory if needed + migrate_config_from_project_dir(config_filename, config_file) + + # Load plugin info and extract versions list + plugin_info = load_json_file(info_file) or {} + versions_list = plugin_info.get('versions', []) + + # Load project config - check if file exists first + config_exists = os.path.exists(config_file) + config = load_json_file(config_file) or {} + + # If no config file exists (first run), just create it with latest version + # without showing any changelog - the user doesn't need version history + if not config_exists: + if versions_list: + # Find the latest version + latest = max(versions_list, key=lambda x: parse_version(x.get('version', ''))) + config['lastVersion'] = latest.get('version', '') + save_json_file(config_file, config) + return "", False + + last_version = config.get('lastVersion', '') + + # Find new versions + new_versions = get_new_versions(versions_list, last_version) + + if not new_versions: + return "", False + + # Build changelog + header = plugin_name or "Plugin" + changelog = f"\n\n## {header} Updates\n" + for entry in new_versions: + v = entry.get('version', 'unknown') + changes = entry.get('changes', 'No description') + changelog += f"\n### v{v}\n{changes}\n" + + # Update lastVersion + latest_version = new_versions[-1].get('version', '') + if latest_version: + config['lastVersion'] = latest_version + save_json_file(config_file, config) + + return changelog, True diff --git a/PluginBase/common b/PluginBase/common deleted file mode 120000 index 60d3b0a..0000000 --- a/PluginBase/common +++ /dev/null @@ -1 +0,0 @@ -../common \ No newline at end of file diff --git a/PluginBase/common/detect_xcode_mcp.py b/PluginBase/common/detect_xcode_mcp.py new file mode 100755 index 0000000..9518457 --- /dev/null +++ b/PluginBase/common/detect_xcode_mcp.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# Generated from common/detect_xcode_mcp.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/detect_xcode_mcp.py and rerun scripts/sync-plugin-common.py. +"""Detect whether Xcode's native MCP server is available.""" + +import subprocess + + +def detect_xcode_mcp() -> dict: + """Check if Xcode MCP bridge is installed and likely active. + + Returns dict with: + xcode_mcp_installed: xcrun --find mcpbridge succeeds + xcode_running: Xcode process is running + mcpbridge_running: mcpbridge process is running + xcode_mcp_likely: installed AND (xcode OR bridge running) + """ + result = { + "xcode_mcp_installed": False, + "xcode_running": False, + "mcpbridge_running": False, + "xcode_mcp_likely": False, + } + + # Check if mcpbridge binary exists (Xcode 26.3+) + try: + subprocess.run( + ["xcrun", "--find", "mcpbridge"], + capture_output=True, timeout=5 + ).returncode == 0 and result.update({"xcode_mcp_installed": True}) + except Exception: + pass + + if not result["xcode_mcp_installed"]: + return result + + # Check if Xcode is running + try: + if subprocess.run( + ["pgrep", "-x", "Xcode"], + capture_output=True, timeout=3 + ).returncode == 0: + result["xcode_running"] = True + except Exception: + pass + + # Check if mcpbridge is running + try: + if subprocess.run( + ["pgrep", "-f", "mcpbridge"], + capture_output=True, timeout=3 + ).returncode == 0: + result["mcpbridge_running"] = True + except Exception: + pass + + result["xcode_mcp_likely"] = ( + result["xcode_running"] or result["mcpbridge_running"] + ) + return result diff --git a/PluginBase/common/hooks.json b/PluginBase/common/hooks.json new file mode 100644 index 0000000..c67edb9 --- /dev/null +++ b/PluginBase/common/hooks.json @@ -0,0 +1,26 @@ +{ + "$comment": "Generated from common/hooks.json by scripts/sync-plugin-common.py. Do not edit this copy; edit common/hooks.json and rerun scripts/sync-plugin-common.py.", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/common/session-start.py \"${CLAUDE_PLUGIN_ROOT}\"" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|Skill", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/common/pre-tool-use.py \"${CLAUDE_PLUGIN_ROOT}\"" + } + ] + } + ] + } +} diff --git a/PluginBase/common/pre-tool-use.py b/PluginBase/common/pre-tool-use.py new file mode 100755 index 0000000..163ddf6 --- /dev/null +++ b/PluginBase/common/pre-tool-use.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# Generated from common/pre-tool-use.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/pre-tool-use.py and rerun scripts/sync-plugin-common.py. + +import sys +import json +import os +import re + +from version_tracker import load_json_file, save_json_file + +def allow(): + response = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow" + } + } + print(json.dumps(response)) + sys.exit(0) + +def deny(message: str): + response = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": message + } + } + print(json.dumps(response)) + sys.exit(0) + + +def main(): + try: + # Get plugin name from plugin.json + plugin_dir = os.environ.get('CLAUDE_PLUGIN_ROOT', '') + plugin_json_path = os.path.join(plugin_dir, ".claude-plugin", "plugin.json") + plugin_json = load_json_file(plugin_json_path) or {} + plugin_name = plugin_json.get('name', '') + + info_file = os.path.join(plugin_dir, "info.json") + info_data = load_json_file(info_file) or {} + skills = info_data.get('skills', []) + + # Read JSON from stdin + input_data = json.load(sys.stdin) + + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + command = tool_input.get("command", "") + + used_skill = tool_input.get("skill", "") + + if plugin_name and tool_name == "Skill" and (used_skill in skills or used_skill.startswith(f"{plugin_name}:")): + allow() + + if tool_name == "Bash" and f"{plugin_dir}/skills/" in command: + allow() + + # Check pre-tool-use rules from info.json + if tool_name == "Bash" and command: + rules = info_data.get('pre-tool-use-rules', []) + + for rule in rules: + rule_name = rule.get('name', '') + match_pattern = rule.get('match', '') + not_match_pattern = rule.get('not_match', '') + decision = rule.get('decision', 'deny') + message = rule.get('message', 'Command denied by rule') + + if not match_pattern or not re.search(match_pattern, command): + continue + + # If not_match is specified and matches, skip this rule + if not_match_pattern and re.search(not_match_pattern, command): + continue + + # Rule matched - apply decision + if decision == 'allow': + allow() + + if decision == 'deny': + deny(message) + + # decision == 'deny_once' - check session tracking + tmpdir = os.environ.get('TMPDIR', '/tmp') + session_file = os.path.join(tmpdir, f"{plugin_name}.json") + + session_data = load_json_file(session_file) or {} + session_id = input_data.get("session_id", "") + session_rules = session_data.get(session_id, []) + + if rule_name in session_rules: + # Already seen this rule in this session, allow + continue + + # First time seeing this rule in this session - deny and record + session_rules.append(rule_name) + session_data[session_id] = session_rules + save_json_file(session_file, session_data) + deny(message) + + # If conditions don't match, allow the tool to proceed (no output needed) + sys.exit(0) + + except Exception as e: + # On error, allow the tool to proceed (fail open) + sys.stderr.write(f"Hook error: {e}\n") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/PluginBase/common/session-start.py b/PluginBase/common/session-start.py new file mode 100755 index 0000000..6facb5b --- /dev/null +++ b/PluginBase/common/session-start.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# Generated from common/session-start.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/session-start.py and rerun scripts/sync-plugin-common.py. + +import sys +import json +import os +import re + +from version_tracker import check_for_updates, load_json_file + + +def derive_config_filename(plugin_name: str) -> str: + """Derive config filename from plugin name by removing spaces and lowercasing first letter.""" + name_no_spaces = plugin_name.replace(" ", "") + if name_no_spaces: + return name_no_spaces[0].lower() + name_no_spaces[1:] + ".json" + return "plugin.json" + + +def main(): + try: + # Get the plugin directory from environment + plugin_dir = os.environ.get('CLAUDE_PLUGIN_ROOT', '') + if not plugin_dir: + sys.exit(0) + + # Load plugin name from plugin.json + plugin_json_path = os.path.join(plugin_dir, ".claude-plugin", "plugin.json") + plugin_json = load_json_file(plugin_json_path) or {} + plugin_name = plugin_json.get('name', 'Plugin') + + # Load welcome message from info.json + info_json_path = os.path.join(plugin_dir, "info.json") + info_json = load_json_file(info_json_path) or {} + welcome_message = info_json.get('welcomeMessage', '') + + config_filename = derive_config_filename(plugin_name) + md_file = os.path.join(plugin_dir, "session-start.md") + + # Read the markdown file + if os.path.exists(md_file): + with open(md_file, 'r', encoding='utf-8') as f: + additional_context = f.read() + else: + additional_context = "" + + # Detect Xcode MCP availability (fail-open: treat as unavailable on error) + xcode_mcp_likely = False + try: + from detect_xcode_mcp import detect_xcode_mcp + detection = detect_xcode_mcp() + xcode_mcp_likely = detection.get("xcode_mcp_likely", False) + except Exception: + pass + + # Process conditional blocks in markdown (no-op if markers absent) + if xcode_mcp_likely: + # Keep IF_XCODE_MCP content, remove IF_NO_XCODE_MCP content + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'.*?\n?', + '', additional_context, flags=re.DOTALL) + else: + # Keep IF_NO_XCODE_MCP content, remove IF_XCODE_MCP content + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'.*?\n?', + '', additional_context, flags=re.DOTALL) + + system_message = f"The {plugin_name} plugin is loaded and ready." + if xcode_mcp_likely: + system_message += " Xcode MCP server detected." + if welcome_message: + system_message += f" {welcome_message}" + + # Check for version updates + changelog, _ = check_for_updates( + plugin_dir=plugin_dir, + config_filename=config_filename, + plugin_name=plugin_name + ) + if changelog: + system_message += changelog + + # Output the JSON structure with the content from the markdown file + response = { + "systemMessage": system_message, + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": additional_context + } + } + + print(json.dumps(response)) + sys.exit(0) + + except Exception as e: + # On error, exit silently (fail open) + sys.stderr.write(f"Hook error: {e}\n") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/PluginBase/common/version_tracker.py b/PluginBase/common/version_tracker.py new file mode 100644 index 0000000..9a2d4d5 --- /dev/null +++ b/PluginBase/common/version_tracker.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# Generated from common/version_tracker.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/version_tracker.py and rerun scripts/sync-plugin-common.py. +""" +Version tracking utilities for Claude Code-compatible plugins. + +Compares a project's last-seen version against a plugin's version history +and returns changelog information for any new versions. +""" + +import json +import os +import sys + + +def parse_version(version_str): + """Parse a version string like '0.1.0' into a tuple of integers.""" + if not version_str: + return (0, 0, 0) + try: + parts = version_str.split('.') + return tuple(int(p) for p in parts) + except (ValueError, AttributeError): + return (0, 0, 0) + + +def get_new_versions(versions_list, last_version): + """Get all versions newer than last_version, sorted oldest to newest.""" + last_parsed = parse_version(last_version) + new_versions = [] + for entry in versions_list: + v = entry.get('version', '') + if parse_version(v) > last_parsed: + new_versions.append(entry) + # Sort by version (oldest first so changes are listed chronologically) + new_versions.sort(key=lambda x: parse_version(x.get('version', ''))) + return new_versions + + +def load_json_file(file_path): + """Load a JSON file, returning empty dict/list on error.""" + if not os.path.exists(file_path): + return None + try: + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return None + + +def save_json_file(file_path, data): + """Save data to a JSON file, creating parent directories if needed.""" + try: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=4) + return True + except (IOError, OSError): + return False + + +def migrate_config_from_project_dir(config_filename, new_config_file): + """ + Migrate a config file from the old .claude/ project directory to the new + CLAUDE_PLUGIN_DATA location, if it exists and hasn't been migrated yet. + + Args: + config_filename: Name of the config file (e.g., "testPlugin.json") + new_config_file: Full path to the new config file location + """ + # Sanitize config_filename to prevent path traversal + if os.sep in config_filename or '/' in config_filename: + return + + if os.path.exists(new_config_file): + return + + project_path = os.environ.get('CLAUDE_PROJECT_DIR', '') + if not project_path: + return + + old_config_file = os.path.join(project_path, ".claude", config_filename) + if not os.path.exists(old_config_file): + return + + # Migrate: copy old config to new location, then remove old file + old_data = load_json_file(old_config_file) + if old_data is not None: + if save_json_file(new_config_file, old_data): + try: + os.remove(old_config_file) + except OSError as e: + print(f"Warning: could not remove old config file {old_config_file}: {e}", file=sys.stderr) + + +def check_for_updates(plugin_dir, config_filename, plugin_name=None): + """ + Check for plugin updates and return changelog if there are new versions. + + Args: + plugin_dir: Path to the plugin directory (containing info.json) + config_filename: Name of the config file to store in CLAUDE_PLUGIN_DATA (e.g., "testPlugin.json") + plugin_name: Optional name for the changelog header (defaults to "Plugin") + + Returns: + tuple: (changelog_text, updated) where changelog_text is the markdown + changelog (empty string if no updates) and updated is a boolean + indicating whether the config was updated + """ + data_dir = os.environ.get('CLAUDE_PLUGIN_DATA', '') + if not data_dir: + return "", False + + info_file = os.path.join(plugin_dir, "info.json") + if not os.path.exists(info_file): + return "", False + + config_file = os.path.join(data_dir, config_filename) + + # Migrate from old .claude/ project directory if needed + migrate_config_from_project_dir(config_filename, config_file) + + # Load plugin info and extract versions list + plugin_info = load_json_file(info_file) or {} + versions_list = plugin_info.get('versions', []) + + # Load project config - check if file exists first + config_exists = os.path.exists(config_file) + config = load_json_file(config_file) or {} + + # If no config file exists (first run), just create it with latest version + # without showing any changelog - the user doesn't need version history + if not config_exists: + if versions_list: + # Find the latest version + latest = max(versions_list, key=lambda x: parse_version(x.get('version', ''))) + config['lastVersion'] = latest.get('version', '') + save_json_file(config_file, config) + return "", False + + last_version = config.get('lastVersion', '') + + # Find new versions + new_versions = get_new_versions(versions_list, last_version) + + if not new_versions: + return "", False + + # Build changelog + header = plugin_name or "Plugin" + changelog = f"\n\n## {header} Updates\n" + for entry in new_versions: + v = entry.get('version', 'unknown') + changes = entry.get('changes', 'No description') + changelog += f"\n### v{v}\n{changes}\n" + + # Update lastVersion + latest_version = new_versions[-1].get('version', '') + if latest_version: + config['lastVersion'] = latest_version + save_json_file(config_file, config) + + return changelog, True diff --git a/README.md b/README.md index dad96ce..c3bfdea 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,11 @@ After changes, users can refresh: When updating a plugin: -1. Update version in plugin's `plugin.json` -2. Update version in marketplace.json entry -3. Document changes in plugin's README and `info.json` version history +1. If `common/` changed, run `scripts/sync-plugin-common.py` +2. Update version in plugin's `plugin.json` +3. Update version in marketplace.json entry +4. Document changes in plugin's README and `info.json` version history +5. Verify with `scripts/sync-plugin-common.py --check` and `python3 -m unittest discover -s tests -v` ## Development diff --git a/SwiftScaffolding/common b/SwiftScaffolding/common deleted file mode 120000 index 60d3b0a..0000000 --- a/SwiftScaffolding/common +++ /dev/null @@ -1 +0,0 @@ -../common \ No newline at end of file diff --git a/SwiftScaffolding/common/detect_xcode_mcp.py b/SwiftScaffolding/common/detect_xcode_mcp.py new file mode 100755 index 0000000..9518457 --- /dev/null +++ b/SwiftScaffolding/common/detect_xcode_mcp.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# Generated from common/detect_xcode_mcp.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/detect_xcode_mcp.py and rerun scripts/sync-plugin-common.py. +"""Detect whether Xcode's native MCP server is available.""" + +import subprocess + + +def detect_xcode_mcp() -> dict: + """Check if Xcode MCP bridge is installed and likely active. + + Returns dict with: + xcode_mcp_installed: xcrun --find mcpbridge succeeds + xcode_running: Xcode process is running + mcpbridge_running: mcpbridge process is running + xcode_mcp_likely: installed AND (xcode OR bridge running) + """ + result = { + "xcode_mcp_installed": False, + "xcode_running": False, + "mcpbridge_running": False, + "xcode_mcp_likely": False, + } + + # Check if mcpbridge binary exists (Xcode 26.3+) + try: + subprocess.run( + ["xcrun", "--find", "mcpbridge"], + capture_output=True, timeout=5 + ).returncode == 0 and result.update({"xcode_mcp_installed": True}) + except Exception: + pass + + if not result["xcode_mcp_installed"]: + return result + + # Check if Xcode is running + try: + if subprocess.run( + ["pgrep", "-x", "Xcode"], + capture_output=True, timeout=3 + ).returncode == 0: + result["xcode_running"] = True + except Exception: + pass + + # Check if mcpbridge is running + try: + if subprocess.run( + ["pgrep", "-f", "mcpbridge"], + capture_output=True, timeout=3 + ).returncode == 0: + result["mcpbridge_running"] = True + except Exception: + pass + + result["xcode_mcp_likely"] = ( + result["xcode_running"] or result["mcpbridge_running"] + ) + return result diff --git a/SwiftScaffolding/common/hooks.json b/SwiftScaffolding/common/hooks.json new file mode 100644 index 0000000..c67edb9 --- /dev/null +++ b/SwiftScaffolding/common/hooks.json @@ -0,0 +1,26 @@ +{ + "$comment": "Generated from common/hooks.json by scripts/sync-plugin-common.py. Do not edit this copy; edit common/hooks.json and rerun scripts/sync-plugin-common.py.", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/common/session-start.py \"${CLAUDE_PLUGIN_ROOT}\"" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|Skill", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/common/pre-tool-use.py \"${CLAUDE_PLUGIN_ROOT}\"" + } + ] + } + ] + } +} diff --git a/SwiftScaffolding/common/pre-tool-use.py b/SwiftScaffolding/common/pre-tool-use.py new file mode 100755 index 0000000..163ddf6 --- /dev/null +++ b/SwiftScaffolding/common/pre-tool-use.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# Generated from common/pre-tool-use.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/pre-tool-use.py and rerun scripts/sync-plugin-common.py. + +import sys +import json +import os +import re + +from version_tracker import load_json_file, save_json_file + +def allow(): + response = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow" + } + } + print(json.dumps(response)) + sys.exit(0) + +def deny(message: str): + response = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": message + } + } + print(json.dumps(response)) + sys.exit(0) + + +def main(): + try: + # Get plugin name from plugin.json + plugin_dir = os.environ.get('CLAUDE_PLUGIN_ROOT', '') + plugin_json_path = os.path.join(plugin_dir, ".claude-plugin", "plugin.json") + plugin_json = load_json_file(plugin_json_path) or {} + plugin_name = plugin_json.get('name', '') + + info_file = os.path.join(plugin_dir, "info.json") + info_data = load_json_file(info_file) or {} + skills = info_data.get('skills', []) + + # Read JSON from stdin + input_data = json.load(sys.stdin) + + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + command = tool_input.get("command", "") + + used_skill = tool_input.get("skill", "") + + if plugin_name and tool_name == "Skill" and (used_skill in skills or used_skill.startswith(f"{plugin_name}:")): + allow() + + if tool_name == "Bash" and f"{plugin_dir}/skills/" in command: + allow() + + # Check pre-tool-use rules from info.json + if tool_name == "Bash" and command: + rules = info_data.get('pre-tool-use-rules', []) + + for rule in rules: + rule_name = rule.get('name', '') + match_pattern = rule.get('match', '') + not_match_pattern = rule.get('not_match', '') + decision = rule.get('decision', 'deny') + message = rule.get('message', 'Command denied by rule') + + if not match_pattern or not re.search(match_pattern, command): + continue + + # If not_match is specified and matches, skip this rule + if not_match_pattern and re.search(not_match_pattern, command): + continue + + # Rule matched - apply decision + if decision == 'allow': + allow() + + if decision == 'deny': + deny(message) + + # decision == 'deny_once' - check session tracking + tmpdir = os.environ.get('TMPDIR', '/tmp') + session_file = os.path.join(tmpdir, f"{plugin_name}.json") + + session_data = load_json_file(session_file) or {} + session_id = input_data.get("session_id", "") + session_rules = session_data.get(session_id, []) + + if rule_name in session_rules: + # Already seen this rule in this session, allow + continue + + # First time seeing this rule in this session - deny and record + session_rules.append(rule_name) + session_data[session_id] = session_rules + save_json_file(session_file, session_data) + deny(message) + + # If conditions don't match, allow the tool to proceed (no output needed) + sys.exit(0) + + except Exception as e: + # On error, allow the tool to proceed (fail open) + sys.stderr.write(f"Hook error: {e}\n") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/SwiftScaffolding/common/session-start.py b/SwiftScaffolding/common/session-start.py new file mode 100755 index 0000000..6facb5b --- /dev/null +++ b/SwiftScaffolding/common/session-start.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# Generated from common/session-start.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/session-start.py and rerun scripts/sync-plugin-common.py. + +import sys +import json +import os +import re + +from version_tracker import check_for_updates, load_json_file + + +def derive_config_filename(plugin_name: str) -> str: + """Derive config filename from plugin name by removing spaces and lowercasing first letter.""" + name_no_spaces = plugin_name.replace(" ", "") + if name_no_spaces: + return name_no_spaces[0].lower() + name_no_spaces[1:] + ".json" + return "plugin.json" + + +def main(): + try: + # Get the plugin directory from environment + plugin_dir = os.environ.get('CLAUDE_PLUGIN_ROOT', '') + if not plugin_dir: + sys.exit(0) + + # Load plugin name from plugin.json + plugin_json_path = os.path.join(plugin_dir, ".claude-plugin", "plugin.json") + plugin_json = load_json_file(plugin_json_path) or {} + plugin_name = plugin_json.get('name', 'Plugin') + + # Load welcome message from info.json + info_json_path = os.path.join(plugin_dir, "info.json") + info_json = load_json_file(info_json_path) or {} + welcome_message = info_json.get('welcomeMessage', '') + + config_filename = derive_config_filename(plugin_name) + md_file = os.path.join(plugin_dir, "session-start.md") + + # Read the markdown file + if os.path.exists(md_file): + with open(md_file, 'r', encoding='utf-8') as f: + additional_context = f.read() + else: + additional_context = "" + + # Detect Xcode MCP availability (fail-open: treat as unavailable on error) + xcode_mcp_likely = False + try: + from detect_xcode_mcp import detect_xcode_mcp + detection = detect_xcode_mcp() + xcode_mcp_likely = detection.get("xcode_mcp_likely", False) + except Exception: + pass + + # Process conditional blocks in markdown (no-op if markers absent) + if xcode_mcp_likely: + # Keep IF_XCODE_MCP content, remove IF_NO_XCODE_MCP content + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'.*?\n?', + '', additional_context, flags=re.DOTALL) + else: + # Keep IF_NO_XCODE_MCP content, remove IF_XCODE_MCP content + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'.*?\n?', + '', additional_context, flags=re.DOTALL) + + system_message = f"The {plugin_name} plugin is loaded and ready." + if xcode_mcp_likely: + system_message += " Xcode MCP server detected." + if welcome_message: + system_message += f" {welcome_message}" + + # Check for version updates + changelog, _ = check_for_updates( + plugin_dir=plugin_dir, + config_filename=config_filename, + plugin_name=plugin_name + ) + if changelog: + system_message += changelog + + # Output the JSON structure with the content from the markdown file + response = { + "systemMessage": system_message, + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": additional_context + } + } + + print(json.dumps(response)) + sys.exit(0) + + except Exception as e: + # On error, exit silently (fail open) + sys.stderr.write(f"Hook error: {e}\n") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/SwiftScaffolding/common/version_tracker.py b/SwiftScaffolding/common/version_tracker.py new file mode 100644 index 0000000..9a2d4d5 --- /dev/null +++ b/SwiftScaffolding/common/version_tracker.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# Generated from common/version_tracker.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/version_tracker.py and rerun scripts/sync-plugin-common.py. +""" +Version tracking utilities for Claude Code-compatible plugins. + +Compares a project's last-seen version against a plugin's version history +and returns changelog information for any new versions. +""" + +import json +import os +import sys + + +def parse_version(version_str): + """Parse a version string like '0.1.0' into a tuple of integers.""" + if not version_str: + return (0, 0, 0) + try: + parts = version_str.split('.') + return tuple(int(p) for p in parts) + except (ValueError, AttributeError): + return (0, 0, 0) + + +def get_new_versions(versions_list, last_version): + """Get all versions newer than last_version, sorted oldest to newest.""" + last_parsed = parse_version(last_version) + new_versions = [] + for entry in versions_list: + v = entry.get('version', '') + if parse_version(v) > last_parsed: + new_versions.append(entry) + # Sort by version (oldest first so changes are listed chronologically) + new_versions.sort(key=lambda x: parse_version(x.get('version', ''))) + return new_versions + + +def load_json_file(file_path): + """Load a JSON file, returning empty dict/list on error.""" + if not os.path.exists(file_path): + return None + try: + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return None + + +def save_json_file(file_path, data): + """Save data to a JSON file, creating parent directories if needed.""" + try: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=4) + return True + except (IOError, OSError): + return False + + +def migrate_config_from_project_dir(config_filename, new_config_file): + """ + Migrate a config file from the old .claude/ project directory to the new + CLAUDE_PLUGIN_DATA location, if it exists and hasn't been migrated yet. + + Args: + config_filename: Name of the config file (e.g., "testPlugin.json") + new_config_file: Full path to the new config file location + """ + # Sanitize config_filename to prevent path traversal + if os.sep in config_filename or '/' in config_filename: + return + + if os.path.exists(new_config_file): + return + + project_path = os.environ.get('CLAUDE_PROJECT_DIR', '') + if not project_path: + return + + old_config_file = os.path.join(project_path, ".claude", config_filename) + if not os.path.exists(old_config_file): + return + + # Migrate: copy old config to new location, then remove old file + old_data = load_json_file(old_config_file) + if old_data is not None: + if save_json_file(new_config_file, old_data): + try: + os.remove(old_config_file) + except OSError as e: + print(f"Warning: could not remove old config file {old_config_file}: {e}", file=sys.stderr) + + +def check_for_updates(plugin_dir, config_filename, plugin_name=None): + """ + Check for plugin updates and return changelog if there are new versions. + + Args: + plugin_dir: Path to the plugin directory (containing info.json) + config_filename: Name of the config file to store in CLAUDE_PLUGIN_DATA (e.g., "testPlugin.json") + plugin_name: Optional name for the changelog header (defaults to "Plugin") + + Returns: + tuple: (changelog_text, updated) where changelog_text is the markdown + changelog (empty string if no updates) and updated is a boolean + indicating whether the config was updated + """ + data_dir = os.environ.get('CLAUDE_PLUGIN_DATA', '') + if not data_dir: + return "", False + + info_file = os.path.join(plugin_dir, "info.json") + if not os.path.exists(info_file): + return "", False + + config_file = os.path.join(data_dir, config_filename) + + # Migrate from old .claude/ project directory if needed + migrate_config_from_project_dir(config_filename, config_file) + + # Load plugin info and extract versions list + plugin_info = load_json_file(info_file) or {} + versions_list = plugin_info.get('versions', []) + + # Load project config - check if file exists first + config_exists = os.path.exists(config_file) + config = load_json_file(config_file) or {} + + # If no config file exists (first run), just create it with latest version + # without showing any changelog - the user doesn't need version history + if not config_exists: + if versions_list: + # Find the latest version + latest = max(versions_list, key=lambda x: parse_version(x.get('version', ''))) + config['lastVersion'] = latest.get('version', '') + save_json_file(config_file, config) + return "", False + + last_version = config.get('lastVersion', '') + + # Find new versions + new_versions = get_new_versions(versions_list, last_version) + + if not new_versions: + return "", False + + # Build changelog + header = plugin_name or "Plugin" + changelog = f"\n\n## {header} Updates\n" + for entry in new_versions: + v = entry.get('version', 'unknown') + changes = entry.get('changes', 'No description') + changelog += f"\n### v{v}\n{changes}\n" + + # Update lastVersion + latest_version = new_versions[-1].get('version', '') + if latest_version: + config['lastVersion'] = latest_version + save_json_file(config_file, config) + + return changelog, True diff --git a/XcodeBuildTools/README.md b/XcodeBuildTools/README.md index 45878dc..3682e7d 100644 --- a/XcodeBuildTools/README.md +++ b/XcodeBuildTools/README.md @@ -49,10 +49,10 @@ Skills with **no MCP equivalent**: `device-app`, `sim-log`, `xcode-doctor`, `mac ### Auto-Approve Hook -The plugin includes an async hook that automatically clicks "Allow" on Xcode's MCP authorization dialog. It: +The plugin includes a backgrounded SessionStart hook that automatically clicks "Allow" on Xcode's MCP authorization dialog. It: - Runs only when Xcode is active and `mcpbridge` exists -- Uses a PID-based lock file to avoid re-running for the same Xcode instance -- Times out silently after 10 seconds if no dialog appears +- Does not require host-level `async` hook support +- Times out silently if no dialog appears **Prerequisite**: Grant Accessibility access to your terminal app in System Settings > Privacy & Security > Accessibility. The script will prompt you if this is missing. diff --git a/XcodeBuildTools/common b/XcodeBuildTools/common deleted file mode 120000 index 60d3b0a..0000000 --- a/XcodeBuildTools/common +++ /dev/null @@ -1 +0,0 @@ -../common \ No newline at end of file diff --git a/XcodeBuildTools/common/detect_xcode_mcp.py b/XcodeBuildTools/common/detect_xcode_mcp.py new file mode 100755 index 0000000..9518457 --- /dev/null +++ b/XcodeBuildTools/common/detect_xcode_mcp.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# Generated from common/detect_xcode_mcp.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/detect_xcode_mcp.py and rerun scripts/sync-plugin-common.py. +"""Detect whether Xcode's native MCP server is available.""" + +import subprocess + + +def detect_xcode_mcp() -> dict: + """Check if Xcode MCP bridge is installed and likely active. + + Returns dict with: + xcode_mcp_installed: xcrun --find mcpbridge succeeds + xcode_running: Xcode process is running + mcpbridge_running: mcpbridge process is running + xcode_mcp_likely: installed AND (xcode OR bridge running) + """ + result = { + "xcode_mcp_installed": False, + "xcode_running": False, + "mcpbridge_running": False, + "xcode_mcp_likely": False, + } + + # Check if mcpbridge binary exists (Xcode 26.3+) + try: + subprocess.run( + ["xcrun", "--find", "mcpbridge"], + capture_output=True, timeout=5 + ).returncode == 0 and result.update({"xcode_mcp_installed": True}) + except Exception: + pass + + if not result["xcode_mcp_installed"]: + return result + + # Check if Xcode is running + try: + if subprocess.run( + ["pgrep", "-x", "Xcode"], + capture_output=True, timeout=3 + ).returncode == 0: + result["xcode_running"] = True + except Exception: + pass + + # Check if mcpbridge is running + try: + if subprocess.run( + ["pgrep", "-f", "mcpbridge"], + capture_output=True, timeout=3 + ).returncode == 0: + result["mcpbridge_running"] = True + except Exception: + pass + + result["xcode_mcp_likely"] = ( + result["xcode_running"] or result["mcpbridge_running"] + ) + return result diff --git a/XcodeBuildTools/common/hooks.json b/XcodeBuildTools/common/hooks.json new file mode 100644 index 0000000..c67edb9 --- /dev/null +++ b/XcodeBuildTools/common/hooks.json @@ -0,0 +1,26 @@ +{ + "$comment": "Generated from common/hooks.json by scripts/sync-plugin-common.py. Do not edit this copy; edit common/hooks.json and rerun scripts/sync-plugin-common.py.", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/common/session-start.py \"${CLAUDE_PLUGIN_ROOT}\"" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|Skill", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/common/pre-tool-use.py \"${CLAUDE_PLUGIN_ROOT}\"" + } + ] + } + ] + } +} diff --git a/XcodeBuildTools/common/pre-tool-use.py b/XcodeBuildTools/common/pre-tool-use.py new file mode 100755 index 0000000..163ddf6 --- /dev/null +++ b/XcodeBuildTools/common/pre-tool-use.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# Generated from common/pre-tool-use.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/pre-tool-use.py and rerun scripts/sync-plugin-common.py. + +import sys +import json +import os +import re + +from version_tracker import load_json_file, save_json_file + +def allow(): + response = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow" + } + } + print(json.dumps(response)) + sys.exit(0) + +def deny(message: str): + response = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": message + } + } + print(json.dumps(response)) + sys.exit(0) + + +def main(): + try: + # Get plugin name from plugin.json + plugin_dir = os.environ.get('CLAUDE_PLUGIN_ROOT', '') + plugin_json_path = os.path.join(plugin_dir, ".claude-plugin", "plugin.json") + plugin_json = load_json_file(plugin_json_path) or {} + plugin_name = plugin_json.get('name', '') + + info_file = os.path.join(plugin_dir, "info.json") + info_data = load_json_file(info_file) or {} + skills = info_data.get('skills', []) + + # Read JSON from stdin + input_data = json.load(sys.stdin) + + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + command = tool_input.get("command", "") + + used_skill = tool_input.get("skill", "") + + if plugin_name and tool_name == "Skill" and (used_skill in skills or used_skill.startswith(f"{plugin_name}:")): + allow() + + if tool_name == "Bash" and f"{plugin_dir}/skills/" in command: + allow() + + # Check pre-tool-use rules from info.json + if tool_name == "Bash" and command: + rules = info_data.get('pre-tool-use-rules', []) + + for rule in rules: + rule_name = rule.get('name', '') + match_pattern = rule.get('match', '') + not_match_pattern = rule.get('not_match', '') + decision = rule.get('decision', 'deny') + message = rule.get('message', 'Command denied by rule') + + if not match_pattern or not re.search(match_pattern, command): + continue + + # If not_match is specified and matches, skip this rule + if not_match_pattern and re.search(not_match_pattern, command): + continue + + # Rule matched - apply decision + if decision == 'allow': + allow() + + if decision == 'deny': + deny(message) + + # decision == 'deny_once' - check session tracking + tmpdir = os.environ.get('TMPDIR', '/tmp') + session_file = os.path.join(tmpdir, f"{plugin_name}.json") + + session_data = load_json_file(session_file) or {} + session_id = input_data.get("session_id", "") + session_rules = session_data.get(session_id, []) + + if rule_name in session_rules: + # Already seen this rule in this session, allow + continue + + # First time seeing this rule in this session - deny and record + session_rules.append(rule_name) + session_data[session_id] = session_rules + save_json_file(session_file, session_data) + deny(message) + + # If conditions don't match, allow the tool to proceed (no output needed) + sys.exit(0) + + except Exception as e: + # On error, allow the tool to proceed (fail open) + sys.stderr.write(f"Hook error: {e}\n") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/XcodeBuildTools/common/session-start.py b/XcodeBuildTools/common/session-start.py new file mode 100755 index 0000000..6facb5b --- /dev/null +++ b/XcodeBuildTools/common/session-start.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# Generated from common/session-start.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/session-start.py and rerun scripts/sync-plugin-common.py. + +import sys +import json +import os +import re + +from version_tracker import check_for_updates, load_json_file + + +def derive_config_filename(plugin_name: str) -> str: + """Derive config filename from plugin name by removing spaces and lowercasing first letter.""" + name_no_spaces = plugin_name.replace(" ", "") + if name_no_spaces: + return name_no_spaces[0].lower() + name_no_spaces[1:] + ".json" + return "plugin.json" + + +def main(): + try: + # Get the plugin directory from environment + plugin_dir = os.environ.get('CLAUDE_PLUGIN_ROOT', '') + if not plugin_dir: + sys.exit(0) + + # Load plugin name from plugin.json + plugin_json_path = os.path.join(plugin_dir, ".claude-plugin", "plugin.json") + plugin_json = load_json_file(plugin_json_path) or {} + plugin_name = plugin_json.get('name', 'Plugin') + + # Load welcome message from info.json + info_json_path = os.path.join(plugin_dir, "info.json") + info_json = load_json_file(info_json_path) or {} + welcome_message = info_json.get('welcomeMessage', '') + + config_filename = derive_config_filename(plugin_name) + md_file = os.path.join(plugin_dir, "session-start.md") + + # Read the markdown file + if os.path.exists(md_file): + with open(md_file, 'r', encoding='utf-8') as f: + additional_context = f.read() + else: + additional_context = "" + + # Detect Xcode MCP availability (fail-open: treat as unavailable on error) + xcode_mcp_likely = False + try: + from detect_xcode_mcp import detect_xcode_mcp + detection = detect_xcode_mcp() + xcode_mcp_likely = detection.get("xcode_mcp_likely", False) + except Exception: + pass + + # Process conditional blocks in markdown (no-op if markers absent) + if xcode_mcp_likely: + # Keep IF_XCODE_MCP content, remove IF_NO_XCODE_MCP content + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'.*?\n?', + '', additional_context, flags=re.DOTALL) + else: + # Keep IF_NO_XCODE_MCP content, remove IF_XCODE_MCP content + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'.*?\n?', + '', additional_context, flags=re.DOTALL) + + system_message = f"The {plugin_name} plugin is loaded and ready." + if xcode_mcp_likely: + system_message += " Xcode MCP server detected." + if welcome_message: + system_message += f" {welcome_message}" + + # Check for version updates + changelog, _ = check_for_updates( + plugin_dir=plugin_dir, + config_filename=config_filename, + plugin_name=plugin_name + ) + if changelog: + system_message += changelog + + # Output the JSON structure with the content from the markdown file + response = { + "systemMessage": system_message, + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": additional_context + } + } + + print(json.dumps(response)) + sys.exit(0) + + except Exception as e: + # On error, exit silently (fail open) + sys.stderr.write(f"Hook error: {e}\n") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/XcodeBuildTools/common/version_tracker.py b/XcodeBuildTools/common/version_tracker.py new file mode 100644 index 0000000..9a2d4d5 --- /dev/null +++ b/XcodeBuildTools/common/version_tracker.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# Generated from common/version_tracker.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/version_tracker.py and rerun scripts/sync-plugin-common.py. +""" +Version tracking utilities for Claude Code-compatible plugins. + +Compares a project's last-seen version against a plugin's version history +and returns changelog information for any new versions. +""" + +import json +import os +import sys + + +def parse_version(version_str): + """Parse a version string like '0.1.0' into a tuple of integers.""" + if not version_str: + return (0, 0, 0) + try: + parts = version_str.split('.') + return tuple(int(p) for p in parts) + except (ValueError, AttributeError): + return (0, 0, 0) + + +def get_new_versions(versions_list, last_version): + """Get all versions newer than last_version, sorted oldest to newest.""" + last_parsed = parse_version(last_version) + new_versions = [] + for entry in versions_list: + v = entry.get('version', '') + if parse_version(v) > last_parsed: + new_versions.append(entry) + # Sort by version (oldest first so changes are listed chronologically) + new_versions.sort(key=lambda x: parse_version(x.get('version', ''))) + return new_versions + + +def load_json_file(file_path): + """Load a JSON file, returning empty dict/list on error.""" + if not os.path.exists(file_path): + return None + try: + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return None + + +def save_json_file(file_path, data): + """Save data to a JSON file, creating parent directories if needed.""" + try: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=4) + return True + except (IOError, OSError): + return False + + +def migrate_config_from_project_dir(config_filename, new_config_file): + """ + Migrate a config file from the old .claude/ project directory to the new + CLAUDE_PLUGIN_DATA location, if it exists and hasn't been migrated yet. + + Args: + config_filename: Name of the config file (e.g., "testPlugin.json") + new_config_file: Full path to the new config file location + """ + # Sanitize config_filename to prevent path traversal + if os.sep in config_filename or '/' in config_filename: + return + + if os.path.exists(new_config_file): + return + + project_path = os.environ.get('CLAUDE_PROJECT_DIR', '') + if not project_path: + return + + old_config_file = os.path.join(project_path, ".claude", config_filename) + if not os.path.exists(old_config_file): + return + + # Migrate: copy old config to new location, then remove old file + old_data = load_json_file(old_config_file) + if old_data is not None: + if save_json_file(new_config_file, old_data): + try: + os.remove(old_config_file) + except OSError as e: + print(f"Warning: could not remove old config file {old_config_file}: {e}", file=sys.stderr) + + +def check_for_updates(plugin_dir, config_filename, plugin_name=None): + """ + Check for plugin updates and return changelog if there are new versions. + + Args: + plugin_dir: Path to the plugin directory (containing info.json) + config_filename: Name of the config file to store in CLAUDE_PLUGIN_DATA (e.g., "testPlugin.json") + plugin_name: Optional name for the changelog header (defaults to "Plugin") + + Returns: + tuple: (changelog_text, updated) where changelog_text is the markdown + changelog (empty string if no updates) and updated is a boolean + indicating whether the config was updated + """ + data_dir = os.environ.get('CLAUDE_PLUGIN_DATA', '') + if not data_dir: + return "", False + + info_file = os.path.join(plugin_dir, "info.json") + if not os.path.exists(info_file): + return "", False + + config_file = os.path.join(data_dir, config_filename) + + # Migrate from old .claude/ project directory if needed + migrate_config_from_project_dir(config_filename, config_file) + + # Load plugin info and extract versions list + plugin_info = load_json_file(info_file) or {} + versions_list = plugin_info.get('versions', []) + + # Load project config - check if file exists first + config_exists = os.path.exists(config_file) + config = load_json_file(config_file) or {} + + # If no config file exists (first run), just create it with latest version + # without showing any changelog - the user doesn't need version history + if not config_exists: + if versions_list: + # Find the latest version + latest = max(versions_list, key=lambda x: parse_version(x.get('version', ''))) + config['lastVersion'] = latest.get('version', '') + save_json_file(config_file, config) + return "", False + + last_version = config.get('lastVersion', '') + + # Find new versions + new_versions = get_new_versions(versions_list, last_version) + + if not new_versions: + return "", False + + # Build changelog + header = plugin_name or "Plugin" + changelog = f"\n\n## {header} Updates\n" + for entry in new_versions: + v = entry.get('version', 'unknown') + changes = entry.get('changes', 'No description') + changelog += f"\n### v{v}\n{changes}\n" + + # Update lastVersion + latest_version = new_versions[-1].get('version', '') + if latest_version: + config['lastVersion'] = latest_version + save_json_file(config_file, config) + + return changelog, True diff --git a/XcodeBuildTools/hooks/approve-xcode-mcp.sh b/XcodeBuildTools/hooks/approve-xcode-mcp.sh index 139819c..c62bf74 100755 --- a/XcodeBuildTools/hooks/approve-xcode-mcp.sh +++ b/XcodeBuildTools/hooks/approve-xcode-mcp.sh @@ -2,7 +2,7 @@ # approve-xcode-mcp.sh # Auto-clicks "Allow" on Xcode's MCP agent authorization dialog. # -# Runs as an async hook (async: true in hooks.json) so it doesn't block startup. +# Started through run-background.sh so it doesn't block startup. # 1. Pre-checks: Xcode must be running and mcpbridge must exist # 2. Checks Accessibility permissions (notifies + opens Settings if missing) # 3. Polls Xcode's windows for the auth dialog (up to 15 seconds) diff --git a/XcodeBuildTools/hooks/hooks.json b/XcodeBuildTools/hooks/hooks.json index d8dc7a0..b78ef20 100644 --- a/XcodeBuildTools/hooks/hooks.json +++ b/XcodeBuildTools/hooks/hooks.json @@ -13,13 +13,11 @@ }, { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/setup-sandbox.sh", - "async": true + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/run-background.sh hooks/setup-sandbox.sh" }, { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/approve-xcode-mcp.sh", - "async": true + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/run-background.sh hooks/approve-xcode-mcp.sh" } ] } diff --git a/XcodeBuildTools/hooks/lib/sandbox.sh b/XcodeBuildTools/hooks/lib/sandbox.sh index 03636ca..4440b65 100644 --- a/XcodeBuildTools/hooks/lib/sandbox.sh +++ b/XcodeBuildTools/hooks/lib/sandbox.sh @@ -3,8 +3,8 @@ # sandbox.sh — Sourceable helpers shared by write-env.sh and setup-sandbox.sh. # # Single source of truth for the inheritance-discovery contract. Both -# the sync SessionStart hook (write-env.sh) and the async one -# (setup-sandbox.sh) need to decide whether the current session is +# the sync SessionStart hook (write-env.sh) and the backgrounded +# setup hook (setup-sandbox.sh) need to decide whether the current session is # inheriting a prior sandbox across /clear — and they must agree on # *which* anchor. Drift here means write-env.sh embeds one path in # $CLAUDE_ENV_FILE while setup-sandbox.sh creates a symlink to a diff --git a/XcodeBuildTools/hooks/run-background.sh b/XcodeBuildTools/hooks/run-background.sh new file mode 100755 index 0000000..402b818 --- /dev/null +++ b/XcodeBuildTools/hooks/run-background.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# +# run-background.sh — Start a hook helper without requiring host-level async support. +# +# Some compatible hosts do not support `async: true` in hooks.json yet. This +# wrapper keeps the hook configuration portable by accepting the hook payload on +# stdin, saving it to a temp file, and launching the real helper in the +# background with stdin restored from that file. + +set -euo pipefail + +relative_target="${1:-}" +if [[ -z "$relative_target" || "$relative_target" == /* || "$relative_target" == *".."* ]]; then + exit 0 +fi + +plugin_root="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +target="$plugin_root/$relative_target" + +if [[ ! -x "$target" ]]; then + exit 0 +fi + +payload_file=$(mktemp "${TMPDIR:-/tmp}/xcodebuildtools-hook.XXXXXX") +cat > "$payload_file" + +( + trap 'rm -f -- "$payload_file"' EXIT + "$target" < "$payload_file" +) >/dev/null 2>&1 & + +exit 0 diff --git a/XcodeBuildTools/hooks/write-env.sh b/XcodeBuildTools/hooks/write-env.sh index ac945d2..8b9ab83 100755 --- a/XcodeBuildTools/hooks/write-env.sh +++ b/XcodeBuildTools/hooks/write-env.sh @@ -7,9 +7,10 @@ # - SANDBOX_DERIVED_DATA / SANDBOX_PACKAGES pointing at this session's # sandbox base # -# Runs SYNC and FIRST in the SessionStart array so the exports are +# Runs sync and first in the SessionStart array so the exports are # available to every subsequent Bash command. setup-sandbox.sh, which -# does the slower dir-creation + peer sweep, keeps running async. +# does the slower dir-creation + peer sweep, is launched through +# run-background.sh. # # Anchor detection: on /clear the prior session's sandbox dir is owned # by our $PPID (the host agent is the same process). We scan SANDBOX_ROOT peers diff --git a/iOSSimulator/common b/iOSSimulator/common deleted file mode 120000 index 60d3b0a..0000000 --- a/iOSSimulator/common +++ /dev/null @@ -1 +0,0 @@ -../common \ No newline at end of file diff --git a/iOSSimulator/common/detect_xcode_mcp.py b/iOSSimulator/common/detect_xcode_mcp.py new file mode 100755 index 0000000..9518457 --- /dev/null +++ b/iOSSimulator/common/detect_xcode_mcp.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# Generated from common/detect_xcode_mcp.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/detect_xcode_mcp.py and rerun scripts/sync-plugin-common.py. +"""Detect whether Xcode's native MCP server is available.""" + +import subprocess + + +def detect_xcode_mcp() -> dict: + """Check if Xcode MCP bridge is installed and likely active. + + Returns dict with: + xcode_mcp_installed: xcrun --find mcpbridge succeeds + xcode_running: Xcode process is running + mcpbridge_running: mcpbridge process is running + xcode_mcp_likely: installed AND (xcode OR bridge running) + """ + result = { + "xcode_mcp_installed": False, + "xcode_running": False, + "mcpbridge_running": False, + "xcode_mcp_likely": False, + } + + # Check if mcpbridge binary exists (Xcode 26.3+) + try: + subprocess.run( + ["xcrun", "--find", "mcpbridge"], + capture_output=True, timeout=5 + ).returncode == 0 and result.update({"xcode_mcp_installed": True}) + except Exception: + pass + + if not result["xcode_mcp_installed"]: + return result + + # Check if Xcode is running + try: + if subprocess.run( + ["pgrep", "-x", "Xcode"], + capture_output=True, timeout=3 + ).returncode == 0: + result["xcode_running"] = True + except Exception: + pass + + # Check if mcpbridge is running + try: + if subprocess.run( + ["pgrep", "-f", "mcpbridge"], + capture_output=True, timeout=3 + ).returncode == 0: + result["mcpbridge_running"] = True + except Exception: + pass + + result["xcode_mcp_likely"] = ( + result["xcode_running"] or result["mcpbridge_running"] + ) + return result diff --git a/iOSSimulator/common/hooks.json b/iOSSimulator/common/hooks.json new file mode 100644 index 0000000..c67edb9 --- /dev/null +++ b/iOSSimulator/common/hooks.json @@ -0,0 +1,26 @@ +{ + "$comment": "Generated from common/hooks.json by scripts/sync-plugin-common.py. Do not edit this copy; edit common/hooks.json and rerun scripts/sync-plugin-common.py.", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/common/session-start.py \"${CLAUDE_PLUGIN_ROOT}\"" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|Skill", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/common/pre-tool-use.py \"${CLAUDE_PLUGIN_ROOT}\"" + } + ] + } + ] + } +} diff --git a/iOSSimulator/common/pre-tool-use.py b/iOSSimulator/common/pre-tool-use.py new file mode 100755 index 0000000..163ddf6 --- /dev/null +++ b/iOSSimulator/common/pre-tool-use.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# Generated from common/pre-tool-use.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/pre-tool-use.py and rerun scripts/sync-plugin-common.py. + +import sys +import json +import os +import re + +from version_tracker import load_json_file, save_json_file + +def allow(): + response = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow" + } + } + print(json.dumps(response)) + sys.exit(0) + +def deny(message: str): + response = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": message + } + } + print(json.dumps(response)) + sys.exit(0) + + +def main(): + try: + # Get plugin name from plugin.json + plugin_dir = os.environ.get('CLAUDE_PLUGIN_ROOT', '') + plugin_json_path = os.path.join(plugin_dir, ".claude-plugin", "plugin.json") + plugin_json = load_json_file(plugin_json_path) or {} + plugin_name = plugin_json.get('name', '') + + info_file = os.path.join(plugin_dir, "info.json") + info_data = load_json_file(info_file) or {} + skills = info_data.get('skills', []) + + # Read JSON from stdin + input_data = json.load(sys.stdin) + + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + command = tool_input.get("command", "") + + used_skill = tool_input.get("skill", "") + + if plugin_name and tool_name == "Skill" and (used_skill in skills or used_skill.startswith(f"{plugin_name}:")): + allow() + + if tool_name == "Bash" and f"{plugin_dir}/skills/" in command: + allow() + + # Check pre-tool-use rules from info.json + if tool_name == "Bash" and command: + rules = info_data.get('pre-tool-use-rules', []) + + for rule in rules: + rule_name = rule.get('name', '') + match_pattern = rule.get('match', '') + not_match_pattern = rule.get('not_match', '') + decision = rule.get('decision', 'deny') + message = rule.get('message', 'Command denied by rule') + + if not match_pattern or not re.search(match_pattern, command): + continue + + # If not_match is specified and matches, skip this rule + if not_match_pattern and re.search(not_match_pattern, command): + continue + + # Rule matched - apply decision + if decision == 'allow': + allow() + + if decision == 'deny': + deny(message) + + # decision == 'deny_once' - check session tracking + tmpdir = os.environ.get('TMPDIR', '/tmp') + session_file = os.path.join(tmpdir, f"{plugin_name}.json") + + session_data = load_json_file(session_file) or {} + session_id = input_data.get("session_id", "") + session_rules = session_data.get(session_id, []) + + if rule_name in session_rules: + # Already seen this rule in this session, allow + continue + + # First time seeing this rule in this session - deny and record + session_rules.append(rule_name) + session_data[session_id] = session_rules + save_json_file(session_file, session_data) + deny(message) + + # If conditions don't match, allow the tool to proceed (no output needed) + sys.exit(0) + + except Exception as e: + # On error, allow the tool to proceed (fail open) + sys.stderr.write(f"Hook error: {e}\n") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/iOSSimulator/common/session-start.py b/iOSSimulator/common/session-start.py new file mode 100755 index 0000000..6facb5b --- /dev/null +++ b/iOSSimulator/common/session-start.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# Generated from common/session-start.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/session-start.py and rerun scripts/sync-plugin-common.py. + +import sys +import json +import os +import re + +from version_tracker import check_for_updates, load_json_file + + +def derive_config_filename(plugin_name: str) -> str: + """Derive config filename from plugin name by removing spaces and lowercasing first letter.""" + name_no_spaces = plugin_name.replace(" ", "") + if name_no_spaces: + return name_no_spaces[0].lower() + name_no_spaces[1:] + ".json" + return "plugin.json" + + +def main(): + try: + # Get the plugin directory from environment + plugin_dir = os.environ.get('CLAUDE_PLUGIN_ROOT', '') + if not plugin_dir: + sys.exit(0) + + # Load plugin name from plugin.json + plugin_json_path = os.path.join(plugin_dir, ".claude-plugin", "plugin.json") + plugin_json = load_json_file(plugin_json_path) or {} + plugin_name = plugin_json.get('name', 'Plugin') + + # Load welcome message from info.json + info_json_path = os.path.join(plugin_dir, "info.json") + info_json = load_json_file(info_json_path) or {} + welcome_message = info_json.get('welcomeMessage', '') + + config_filename = derive_config_filename(plugin_name) + md_file = os.path.join(plugin_dir, "session-start.md") + + # Read the markdown file + if os.path.exists(md_file): + with open(md_file, 'r', encoding='utf-8') as f: + additional_context = f.read() + else: + additional_context = "" + + # Detect Xcode MCP availability (fail-open: treat as unavailable on error) + xcode_mcp_likely = False + try: + from detect_xcode_mcp import detect_xcode_mcp + detection = detect_xcode_mcp() + xcode_mcp_likely = detection.get("xcode_mcp_likely", False) + except Exception: + pass + + # Process conditional blocks in markdown (no-op if markers absent) + if xcode_mcp_likely: + # Keep IF_XCODE_MCP content, remove IF_NO_XCODE_MCP content + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'.*?\n?', + '', additional_context, flags=re.DOTALL) + else: + # Keep IF_NO_XCODE_MCP content, remove IF_XCODE_MCP content + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'\n?', '', additional_context) + additional_context = re.sub( + r'.*?\n?', + '', additional_context, flags=re.DOTALL) + + system_message = f"The {plugin_name} plugin is loaded and ready." + if xcode_mcp_likely: + system_message += " Xcode MCP server detected." + if welcome_message: + system_message += f" {welcome_message}" + + # Check for version updates + changelog, _ = check_for_updates( + plugin_dir=plugin_dir, + config_filename=config_filename, + plugin_name=plugin_name + ) + if changelog: + system_message += changelog + + # Output the JSON structure with the content from the markdown file + response = { + "systemMessage": system_message, + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": additional_context + } + } + + print(json.dumps(response)) + sys.exit(0) + + except Exception as e: + # On error, exit silently (fail open) + sys.stderr.write(f"Hook error: {e}\n") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/iOSSimulator/common/version_tracker.py b/iOSSimulator/common/version_tracker.py new file mode 100644 index 0000000..9a2d4d5 --- /dev/null +++ b/iOSSimulator/common/version_tracker.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# Generated from common/version_tracker.py by scripts/sync-plugin-common.py. Do not edit this copy; edit common/version_tracker.py and rerun scripts/sync-plugin-common.py. +""" +Version tracking utilities for Claude Code-compatible plugins. + +Compares a project's last-seen version against a plugin's version history +and returns changelog information for any new versions. +""" + +import json +import os +import sys + + +def parse_version(version_str): + """Parse a version string like '0.1.0' into a tuple of integers.""" + if not version_str: + return (0, 0, 0) + try: + parts = version_str.split('.') + return tuple(int(p) for p in parts) + except (ValueError, AttributeError): + return (0, 0, 0) + + +def get_new_versions(versions_list, last_version): + """Get all versions newer than last_version, sorted oldest to newest.""" + last_parsed = parse_version(last_version) + new_versions = [] + for entry in versions_list: + v = entry.get('version', '') + if parse_version(v) > last_parsed: + new_versions.append(entry) + # Sort by version (oldest first so changes are listed chronologically) + new_versions.sort(key=lambda x: parse_version(x.get('version', ''))) + return new_versions + + +def load_json_file(file_path): + """Load a JSON file, returning empty dict/list on error.""" + if not os.path.exists(file_path): + return None + try: + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return None + + +def save_json_file(file_path, data): + """Save data to a JSON file, creating parent directories if needed.""" + try: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=4) + return True + except (IOError, OSError): + return False + + +def migrate_config_from_project_dir(config_filename, new_config_file): + """ + Migrate a config file from the old .claude/ project directory to the new + CLAUDE_PLUGIN_DATA location, if it exists and hasn't been migrated yet. + + Args: + config_filename: Name of the config file (e.g., "testPlugin.json") + new_config_file: Full path to the new config file location + """ + # Sanitize config_filename to prevent path traversal + if os.sep in config_filename or '/' in config_filename: + return + + if os.path.exists(new_config_file): + return + + project_path = os.environ.get('CLAUDE_PROJECT_DIR', '') + if not project_path: + return + + old_config_file = os.path.join(project_path, ".claude", config_filename) + if not os.path.exists(old_config_file): + return + + # Migrate: copy old config to new location, then remove old file + old_data = load_json_file(old_config_file) + if old_data is not None: + if save_json_file(new_config_file, old_data): + try: + os.remove(old_config_file) + except OSError as e: + print(f"Warning: could not remove old config file {old_config_file}: {e}", file=sys.stderr) + + +def check_for_updates(plugin_dir, config_filename, plugin_name=None): + """ + Check for plugin updates and return changelog if there are new versions. + + Args: + plugin_dir: Path to the plugin directory (containing info.json) + config_filename: Name of the config file to store in CLAUDE_PLUGIN_DATA (e.g., "testPlugin.json") + plugin_name: Optional name for the changelog header (defaults to "Plugin") + + Returns: + tuple: (changelog_text, updated) where changelog_text is the markdown + changelog (empty string if no updates) and updated is a boolean + indicating whether the config was updated + """ + data_dir = os.environ.get('CLAUDE_PLUGIN_DATA', '') + if not data_dir: + return "", False + + info_file = os.path.join(plugin_dir, "info.json") + if not os.path.exists(info_file): + return "", False + + config_file = os.path.join(data_dir, config_filename) + + # Migrate from old .claude/ project directory if needed + migrate_config_from_project_dir(config_filename, config_file) + + # Load plugin info and extract versions list + plugin_info = load_json_file(info_file) or {} + versions_list = plugin_info.get('versions', []) + + # Load project config - check if file exists first + config_exists = os.path.exists(config_file) + config = load_json_file(config_file) or {} + + # If no config file exists (first run), just create it with latest version + # without showing any changelog - the user doesn't need version history + if not config_exists: + if versions_list: + # Find the latest version + latest = max(versions_list, key=lambda x: parse_version(x.get('version', ''))) + config['lastVersion'] = latest.get('version', '') + save_json_file(config_file, config) + return "", False + + last_version = config.get('lastVersion', '') + + # Find new versions + new_versions = get_new_versions(versions_list, last_version) + + if not new_versions: + return "", False + + # Build changelog + header = plugin_name or "Plugin" + changelog = f"\n\n## {header} Updates\n" + for entry in new_versions: + v = entry.get('version', 'unknown') + changes = entry.get('changes', 'No description') + changelog += f"\n### v{v}\n{changes}\n" + + # Update lastVersion + latest_version = new_versions[-1].get('version', '') + if latest_version: + config['lastVersion'] = latest_version + save_json_file(config_file, config) + + return changelog, True diff --git a/scripts/sync-plugin-common.py b/scripts/sync-plugin-common.py new file mode 100755 index 0000000..676ed24 --- /dev/null +++ b/scripts/sync-plugin-common.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Copy root common hook helpers into every local plugin package.""" + +import argparse +import json +import shutil +import sys +from pathlib import Path + + +GENERATED_MESSAGE = ( + "Generated from {source} by scripts/sync-plugin-common.py. " + "Do not edit this copy; edit {source} and rerun scripts/sync-plugin-common.py." +) + + +def plugin_dirs(repo_root): + return sorted( + path.parent.parent + for path in repo_root.glob("*/.claude-plugin/plugin.json") + ) + + +def source_files(common_dir): + return sorted(path for path in common_dir.iterdir() if path.is_file()) + + +def describe(path, repo_root): + try: + return str(path.relative_to(repo_root)) + except ValueError: + return str(path) + + +def generated_bytes(source, repo_root): + source_ref = describe(source, repo_root) + message = GENERATED_MESSAGE.format(source=source_ref) + + if source.suffix == ".json": + payload = json.loads(source.read_text(encoding="utf-8")) + return json.dumps( + {"$comment": message, **payload}, + indent=2, + ).encode("utf-8") + b"\n" + + text = source.read_text(encoding="utf-8") + header = f"# {message}\n" + if text.startswith("#!"): + first_line, separator, remainder = text.partition("\n") + text = first_line + separator + header + remainder + else: + text = header + text + return text.encode("utf-8") + + +def check_plugin_common(repo_root, plugin_dir, files): + issues = [] + common_dir = plugin_dir / "common" + + if common_dir.is_symlink(): + issues.append(f"{describe(common_dir, repo_root)} is a symlink") + return issues + + if not common_dir.is_dir(): + issues.append(f"{describe(common_dir, repo_root)} is missing") + return issues + + expected_names = {path.name for path in files} + actual_files = {path.name: path for path in common_dir.iterdir() if path.is_file()} + + for source in files: + target = common_dir / source.name + if not target.exists(): + issues.append(f"{describe(target, repo_root)} is missing") + elif generated_bytes(source, repo_root) != target.read_bytes(): + issues.append(f"{describe(target, repo_root)} differs from {describe(source, repo_root)}") + + for extra_name in sorted(set(actual_files) - expected_names): + issues.append(f"{describe(actual_files[extra_name], repo_root)} is stale") + + return issues + + +def sync_plugin_common(repo_root, plugin_dir, files): + common_dir = plugin_dir / "common" + + if common_dir.is_symlink() or common_dir.is_file(): + common_dir.unlink() + common_dir.mkdir(exist_ok=True) + + expected_names = {path.name for path in files} + for child in common_dir.iterdir(): + if child.name in expected_names: + continue + if child.is_dir(): + shutil.rmtree(child) + else: + child.unlink() + + for source in files: + target = common_dir / source.name + target.write_bytes(generated_bytes(source, repo_root)) + shutil.copymode(source, target) + + +def main(): + parser = argparse.ArgumentParser( + description="Copy root common hook helpers into every plugin/common directory.", + ) + parser.add_argument( + "--check", + action="store_true", + help="verify plugin/common copies are current without writing files", + ) + parser.add_argument( + "--repo-root", + type=Path, + default=Path(__file__).resolve().parents[1], + help=argparse.SUPPRESS, + ) + args = parser.parse_args() + + repo_root = args.repo_root.resolve() + common_dir = repo_root / "common" + files = source_files(common_dir) + + if args.check: + issues = [] + for plugin_dir in plugin_dirs(repo_root): + issues.extend(check_plugin_common(repo_root, plugin_dir, files)) + if issues: + for issue in issues: + print(issue, file=sys.stderr) + return 1 + return 0 + + for plugin_dir in plugin_dirs(repo_root): + sync_plugin_common(repo_root, plugin_dir, files) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_repository_integrity.py b/tests/test_repository_integrity.py index ab0cbe5..56e7bc1 100644 --- a/tests/test_repository_integrity.py +++ b/tests/test_repository_integrity.py @@ -5,10 +5,12 @@ import subprocess import sys import unittest +import importlib.util from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] +SYNC_COMMON_SCRIPT = REPO_ROOT / "scripts" / "sync-plugin-common.py" def load_json(path): @@ -23,6 +25,16 @@ def plugin_dirs(): ) +def load_sync_common_module(): + spec = importlib.util.spec_from_file_location( + "sync_plugin_common_under_test", + SYNC_COMMON_SCRIPT, + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + class RepositoryIntegrityTests(unittest.TestCase): def test_marketplace_entries_match_local_plugin_manifests(self): marketplace = load_json(REPO_ROOT / ".claude-plugin" / "marketplace.json") @@ -127,6 +139,66 @@ def test_hook_commands_reference_existing_plugin_files(self): with self.subTest(hook=str(hooks_path.relative_to(REPO_ROOT)), command=command): self.assertTrue(executable.exists(), f"{executable} does not exist") + def test_plugin_packages_are_self_contained(self): + for plugin_dir in plugin_dirs(): + for path in plugin_dir.rglob("*"): + if path.is_symlink(): + target = path.resolve() + with self.subTest(path=str(path.relative_to(REPO_ROOT))): + self.assertTrue( + target == plugin_dir.resolve() + or target.is_relative_to(plugin_dir.resolve()), + "plugin packages must not depend on symlinks that escape the plugin directory", + ) + + def test_plugin_common_copies_match_shared_sources(self): + sync_common = load_sync_common_module() + common_files = sorted( + path.name + for path in (REPO_ROOT / "common").iterdir() + if path.is_file() + ) + + for plugin_dir in plugin_dirs(): + common_dir = plugin_dir / "common" + if not common_dir.exists(): + continue + + for common_file in common_files: + plugin_file = common_dir / common_file + shared_file = REPO_ROOT / "common" / common_file + + with self.subTest(plugin=plugin_dir.name, common_file=common_file): + self.assertEqual( + sync_common.generated_bytes(shared_file, REPO_ROOT), + plugin_file.read_bytes(), + ) + + def test_sync_plugin_common_script_reports_clean_checkout(self): + result = subprocess.run( + [sys.executable, str(SYNC_COMMON_SCRIPT), "--check"], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + + def test_plugin_hooks_do_not_require_host_async_support(self): + hooks_paths = list(REPO_ROOT.glob("*/hooks/hooks.json")) + + for hooks_path in hooks_paths: + hooks_config = load_json(hooks_path) + async_paths = self.find_keys(hooks_config, "async") + + with self.subTest(hook=str(hooks_path.relative_to(REPO_ROOT))): + self.assertEqual( + [], + async_paths, + "Codex skips hooks with async=true; use a command that backgrounds its own work instead", + ) + def test_mcp_configs_define_servers(self): for mcp_path in REPO_ROOT.glob("*/.mcp.json"): config = load_json(mcp_path) @@ -253,6 +325,18 @@ def hook_commands(self, value): commands.extend(self.hook_commands(child)) return commands + def find_keys(self, value, key, path="$"): + paths = [] + if isinstance(value, dict): + if key in value: + paths.append(path) + for child_key, child in value.items(): + paths.extend(self.find_keys(child, key, f"{path}.{child_key}")) + elif isinstance(value, list): + for index, child in enumerate(value): + paths.extend(self.find_keys(child, key, f"{path}[{index}]")) + return paths + def split_frontmatter(self, text): match = re.match(r"---\n(?P.*?)\n---\n(?P.*)", text, re.DOTALL) self.assertIsNotNone(match) From 8855ddaedc2d5e3f6ed1beab7e4cff542c76f5ce Mon Sep 17 00:00:00 2001 From: Luke LaBonte Date: Tue, 26 May 2026 15:39:11 -0500 Subject: [PATCH 2/6] Migrate legacy commands to skills - Replace legacy command prompts with SKILL.md files - Register migrated skills and bump plugin release metadata - Update docs and integrity checks for the skill-based plugin surface --- .claude-plugin/marketplace.json | 4 +- .claude/commands/update-plugin.md | 67 ------------------- .claude/skills/update-plugin/SKILL.md | 40 +++++++++++ AGENTS.md | 7 +- CLAUDE.md | 20 ++---- README.md | 13 ++-- SwiftScaffolding/.claude-plugin/plugin.json | 2 +- SwiftScaffolding/README.md | 5 +- SwiftScaffolding/commands/scaffolding.md | 25 ------- SwiftScaffolding/info.json | 10 ++- SwiftScaffolding/skills/scaffolding/SKILL.md | 40 +++++++++++ XcodeBuildTools/.claude-plugin/plugin.json | 2 +- XcodeBuildTools/README.md | 7 +- XcodeBuildTools/commands/analyze.md | 13 ---- XcodeBuildTools/commands/swiftui-modernize.md | 67 ------------------- XcodeBuildTools/info.json | 6 ++ .../skills/swift-code-analysis/SKILL.md | 29 ++++++++ .../skills/swiftui-modernize/SKILL.md | 54 +++++++++++++++ tests/test_repository_integrity.py | 21 +++--- 19 files changed, 217 insertions(+), 215 deletions(-) delete mode 100644 .claude/commands/update-plugin.md create mode 100644 .claude/skills/update-plugin/SKILL.md delete mode 100644 SwiftScaffolding/commands/scaffolding.md create mode 100644 SwiftScaffolding/skills/scaffolding/SKILL.md delete mode 100644 XcodeBuildTools/commands/analyze.md delete mode 100644 XcodeBuildTools/commands/swiftui-modernize.md create mode 100644 XcodeBuildTools/skills/swift-code-analysis/SKILL.md create mode 100644 XcodeBuildTools/skills/swiftui-modernize/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f97422e..c9fd405 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -56,7 +56,7 @@ }, { "name": "SwiftScaffolding", - "version": "0.4.2", + "version": "0.4.3", "description": "Swift project scaffolding and code generation tools", "source": "./SwiftScaffolding", "author": { @@ -73,7 +73,7 @@ }, { "name": "XcodeBuildTools", - "version": "0.5.9", + "version": "0.5.10", "description": "Xcode development tools with optional Xcode MCP integration: build/test/run Swift packages, discover projects and schemes, build for simulator/device/macOS, run tests, manage device apps, capture simulator logs, Sparkle auto-update integration", "source": "./XcodeBuildTools", "author": { diff --git a/.claude/commands/update-plugin.md b/.claude/commands/update-plugin.md deleted file mode 100644 index 19f0804..0000000 --- a/.claude/commands/update-plugin.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -description: Update a plugin in the Claude Code Plugin Marketplace. Follow these steps carefully. ---- -# Update Plugin Version - -You are helping update a plugin in the Claude Code Plugin Marketplace. Follow these steps carefully: - -When you need user input, use the host's native structured question mechanism -if available; otherwise ask normally in chat. - -## Steps to Update a Plugin - -1. **Identify what was updated** - - Look at git changes - - If there are no changes but the branch differs from remote then compare local with remote. - - If there are changes in files in the `common` directory these affect every plugin so we need to update every plugin. - - If files in `common/` changed, run `scripts/sync-plugin-common.py` before editing versions so each plugin package has the current copied helpers. - - If `common/hooks.json` was changed, you must propagate those changes to each plugin's `hooks/hooks.json` file while preserving the unique plugin name in each command (e.g., `session-start.py MarvinOutputStyle` stays unique per plugin). - -2. **Identify the plugin to update** - - Determine what plugin changed and only ask the user if you can't determine using the diff. - - Confirm the new version number (use semantic versioning: major.minor.patch). Suggest one based on the current git changes. - -3. **Update the plugin's manifest** (`/.claude-plugin/plugin.json`) - - Update the `version` field to the new version - -4. **Update the marketplace catalog** (`.claude-plugin/marketplace.json`) - - Find the plugin entry in the `plugins` array - - Update the `version` field to match the new version - -5. **Document the changes** - - Update the plugin's `README.md` with the changes made - - Be clear and concise about what changed in this version - - Add a new entry to the `versions` array in the plugin's `info.json` file - - If the user created a new folder in the plugin's `skills` folder, add the folder name to the `skills` folder in `info.json`. If some folder was renamed on this folder rename it in the array as well. - -6. **Verify the updates** - - Run `scripts/sync-plugin-common.py --check` - - Run `python3 -m unittest discover -s tests -v` - - Show the user a summary of all changes made - - List the files that were modified - - Confirm the version numbers match across all files - -7. **Commit the changes** - - Commit the changes in the plugin's directory and main marketplace catalog - -8. **Tag the plugin release** - - After the commit lands, create a git tag so Claude Code can resolve this version when other plugins depend on it. - - Tag format: `{PluginName}--v{version}` (e.g., `XcodeBuildTools--v0.5.9`). The `{PluginName}` must match the plugin's folder name and the `name` field in `plugin.json` exactly, and `{version}` must match the `version` field in the `plugin.json` at that commit. - - The tag must point to the commit that contains the updated `plugin.json` version. - - Push the tag to the remote: `git push origin {PluginName}--v{version}`. - - If this update touched `common/` and bumped multiple plugins, create and push one tag per bumped plugin. - - Reference: https://code.claude.com/docs/en/plugin-dependencies#tag-plugin-releases-for-version-resolution - -## Important Notes - -- Follow semantic versioning: - - MAJOR version for incompatible API changes - - MINOR version for backwards-compatible functionality additions - - PATCH version for backwards-compatible bug fixes -- Ensure all JSON files remain valid after edits -- The marketplace catalog and plugin manifest versions must match -- The git tag version must also match (plugin.json version, marketplace.json version, and the `v{version}` portion of the tag are all the same string) -- `common/` is the canonical source for shared hook helpers. The installed plugin package must be self-contained, so copied files under each plugin's `common/` directory must be regenerated with `scripts/sync-plugin-common.py` rather than edited by hand. -- Each plugin has its own `hooks/hooks.json` file with unique command identifiers (plugin name baked into commands). When updating hooks: - - The hook structure comes from `common/hooks.json` as reference - - Each plugin's hooks.json must include the plugin name in commands (e.g., `${CLAUDE_PLUGIN_ROOT}/common/session-start.py PluginName`) diff --git a/.claude/skills/update-plugin/SKILL.md b/.claude/skills/update-plugin/SKILL.md new file mode 100644 index 0000000..0928947 --- /dev/null +++ b/.claude/skills/update-plugin/SKILL.md @@ -0,0 +1,40 @@ +--- +name: update-plugin +description: Use when updating, versioning, tagging, or releasing a plugin in this Claude Code-compatible plugin marketplace. +--- + +# Update Plugin + +Use this workflow when preparing a plugin update in this marketplace. + +## Workflow + +1. Inspect the diff. + - If `common/` changed, every plugin is affected. + - Run `scripts/sync-plugin-common.py` before editing versions so each plugin package has current copied helpers. + - If `common/hooks.json` changed, propagate the hook shape to each plugin's `hooks/hooks.json` while preserving plugin-specific command names. +2. Identify the plugin or plugins to update. + - Ask only if the changed plugin is unclear from the diff. + - Suggest a semantic version bump from the change scope. +3. Update version metadata. + - Update `/.claude-plugin/plugin.json`. + - Update the matching entry in `.claude-plugin/marketplace.json`. +4. Document the change. + - Update the plugin README changelog. + - Add a matching entry to the plugin `info.json` `versions` array. + - If skills were added, removed, or renamed, update the plugin `info.json` `skills` array. + - If legacy command prompts are present, migrate them to skills before release. +5. Verify. + - Run `scripts/sync-plugin-common.py --check`. + - Run `python3 -m unittest discover -s tests -v`. + - Confirm plugin manifest, marketplace, README, and `info.json` versions match. +6. Commit and tag after the commit lands. + - Tag format: `{PluginName}--v{version}`. + - Push the tag with `git push origin {PluginName}--v{version}`. + - If multiple plugins were bumped, create and push one tag per plugin. + +## Notes + +- The installed plugin package must be self-contained. Do not edit copied `*/common/` files by hand; edit root `common/` and rerun `scripts/sync-plugin-common.py`. +- JSON files must remain valid. +- Plugin names must match folder names exactly. diff --git a/AGENTS.md b/AGENTS.md index 078bed9..97aa623 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,14 +6,13 @@ those names as compatibility contracts, not as a reason to make agent-facing instructions Claude-specific. Use `CLAUDE.md` for the full repository architecture and release workflow. When -editing skills, commands, READMEs, or session-start context, prefer neutral +editing skills, READMEs, or session-start context, prefer neutral terms like "agent", "assistant", "host", or "session" unless the text is about a Claude Code-specific file, command, environment variable, or historical changelog entry. -Claude-specific tool names are acceptable in Claude-specific metadata such as -slash-command `allowed-tools`. Keep the command body portable by asking for the -host's native mechanism instead of naming a specific tool. +Keep skill bodies portable by asking for the host's native mechanism instead of +naming a specific tool unless a plugin contract requires the concrete name. Keep new runtime checks and packaging expectations covered by: diff --git a/CLAUDE.md b/CLAUDE.md index 0186128..d6bd9bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ The repository has a two-level architecture: 2. **Plugin Level** (subdirectories): - Each plugin subdirectory is a self-contained Claude Code-compatible plugin package - Contains its own `.claude-plugin/plugin.json` manifest - - May include: slash commands (`commands/`), skills (`skills/`), agents (`agents/`), and MCP servers (`.mcp.json`) + - May include: skills (`skills/`), agents (`agents/`), hooks (`hooks/`), and MCP servers (`.mcp.json`) ### Key Schema Requirements @@ -81,9 +81,9 @@ Users add the marketplace, then install plugins from it: } ``` 4. Create plugin components: - - `commands/` - Slash commands (`.md` files) - `skills/` - Agent skills (directories with `SKILL.md`) - `agents/` - Custom agents (`.md` files) + - `hooks/` - Lifecycle hooks and related scripts - `.mcp.json` - MCP server configurations ### Updating Plugins @@ -107,7 +107,7 @@ Every version bump must be tagged so Claude Code can resolve it for plugin depen - **One tag per bumped plugin**: changes under `common/` affect every plugin; if you bump multiple plugins in one PR, create and push one tag per bumped plugin. - **Verify**: `git tag -l '{PluginName}*' --sort=-v:refname` should show the new tag; `git show {tag}:{PluginName}/.claude-plugin/plugin.json` should print the matching version. -The `/update-plugin` skill automates these steps end-to-end. +The `update-plugin` skill automates these steps end-to-end. ### Testing Locally @@ -126,17 +126,6 @@ Test the marketplace and plugins locally before pushing: ## Plugin Component Guidelines -### Slash Commands -- Standalone `.claude/commands` files use the filename as the command name: - `analyze.md` → `/analyze` -- Plugin commands are namespaced by plugin name to avoid collisions: - `SwiftScaffolding/commands/scaffolding.md` → `/SwiftScaffolding:scaffolding` -- Write the command prompt in markdown -- Located in plugin's `commands/` directory -- Claude-specific tool names are acceptable in Claude-specific metadata such as - `allowed-tools`, but keep the command body portable. For user input, say to use - the host's native structured question mechanism if available. - ### MCP Servers - Configured in plugin's `.mcp.json` - Standard format: @@ -217,7 +206,8 @@ if __name__ == "__main__": ### Skills and Agents - Skills: directories in `skills/` with `SKILL.md` - Agents: `.md` files in `agents/` -- Not every plugin has skills or agents; command-only and hook-only plugins are valid. +- Legacy command prompts should be migrated to skills instead of adding `commands/` content. +- Not every plugin has skills or agents; hook-only plugins are valid. ## Git Workflow diff --git a/README.md b/README.md index c3bfdea..bc1eddf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Claude Code Plugins Marketplace -A curated collection of Claude Code-compatible plugins for various development workflows. Each plugin extends agent capabilities with custom slash commands, MCP servers, skills, and agents. +A curated collection of Claude Code-compatible plugins for various development workflows. Each plugin extends agent capabilities with skills, MCP servers, hooks, and agents. The marketplace uses Claude Code's plugin packaging contracts (`.claude-plugin`, `CLAUDE_PLUGIN_ROOT`, plugin install commands), but agent-facing instructions are @@ -55,7 +55,7 @@ Adds Marvin the Paranoid Android personality from *The Hitchhiker's Guide to the ### XcodeBuildTools -Xcode development tools using token-efficient build output. Provides 9 consolidated skills covering the full Xcode development workflow, with optional Xcode MCP integration for MCP-only Xcode context. +Xcode development tools using token-efficient build output. Provides 11 consolidated skills covering the full Xcode development workflow, with optional Xcode MCP integration for MCP-only Xcode context. **Skills:** - `swift-package` - Build, test, run, and manage SPM projects @@ -67,6 +67,8 @@ Xcode development tools using token-efficient build output. Provides 9 consolida - `macos-app` - Launch and stop macOS applications - `sim-log` - Capture logs from iOS Simulator apps - `sparkle-integration` - Integrate Sparkle 2.x auto-update framework +- `swift-code-analysis` - Review Swift code quality, architecture, and correctness +- `swiftui-modernize` - Review SwiftUI files for deprecated APIs and modernization opportunities [View Plugin Documentation →](./XcodeBuildTools/README.md) @@ -82,9 +84,9 @@ Follow the standard Claude Code-compatible plugin structure: YourPlugin/ ├── .claude-plugin/ │ └── plugin.json -├── commands/ # Optional: slash commands ├── skills/ # Optional: agent skills ├── agents/ # Optional: custom agents +├── hooks/ # Optional: lifecycle hooks ├── .mcp.json # Optional: MCP servers └── README.md ``` @@ -119,7 +121,7 @@ YourPlugin/ - Include a comprehensive README.md - Follow semantic versioning -- Test all commands and MCP servers +- Test all skills, hooks, and MCP servers - Document prerequisites and dependencies - Include examples and usage instructions @@ -185,7 +187,7 @@ Use an existing plugin as a template: cp -r XcodeBuildTools YourNewPlugin # Update metadata in .claude-plugin/plugin.json -# Customize commands, skills, agents +# Customize skills, hooks, agents # Update README.md ``` @@ -195,7 +197,6 @@ cp -r XcodeBuildTools YourNewPlugin - [Plugin Development Guide](https://docs.claude.com/en/docs/claude-code/plugins) - [MCP Server Documentation](https://docs.claude.com/en/docs/claude-code/mcp) - [Agent Skills](https://docs.claude.com/en/docs/claude-code/skills) -- [Slash Commands Reference](https://docs.claude.com/en/docs/claude-code/slash-commands) ## License diff --git a/SwiftScaffolding/.claude-plugin/plugin.json b/SwiftScaffolding/.claude-plugin/plugin.json index 640cfcc..7b9cc8d 100644 --- a/SwiftScaffolding/.claude-plugin/plugin.json +++ b/SwiftScaffolding/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "SwiftScaffolding", - "version": "0.4.2", + "version": "0.4.3", "description": "Swift project scaffolding and code generation tools", "author": { "name": "Gustavo Ambrozio" diff --git a/SwiftScaffolding/README.md b/SwiftScaffolding/README.md index d943969..71d8ac4 100644 --- a/SwiftScaffolding/README.md +++ b/SwiftScaffolding/README.md @@ -25,10 +25,13 @@ Swift project scaffolding and code generation tools. ## Usage -Use the `/SwiftScaffolding:scaffolding` command to scaffold Swift projects. +Use the `scaffolding` skill to scaffold Swift projects. ## Changelog +### 0.4.3 +- Migrated the legacy scaffolding prompt to the `scaffolding` skill + ### 0.4.2 - Kept Claude-specific `AskUserQuestion` access in command metadata while making the scaffolding prompt body portable across compatible agents diff --git a/SwiftScaffolding/commands/scaffolding.md b/SwiftScaffolding/commands/scaffolding.md deleted file mode 100644 index e12789f..0000000 --- a/SwiftScaffolding/commands/scaffolding.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -allowed-tools: AskUserQuestion, mcp__plugin_SwiftScaffolding_XcodeBuildMCP -description: Create an iOS or macOS project using XcodeBuildMCP ---- -Ask the user for the project platform, project name, and bundle identifier. -Use the host's native structured question mechanism if available; otherwise ask -normally in chat. - -If it is an iOS project, use the `scaffold_ios_project` tool in XcodeBuildMCP with these parameters: - -projectName: -outputPath: current folder -bundleIdentifier: -displayName: -target device family: universal -deploymentTarget: 18.0 -supported orientations: all for both iPhone and iPad - -If it is a macOS project, use the `scaffold_macos_project` tool in XcodeBuildMCP with these parameters: - -projectName: -outputPath: current folder -bundleIdentifier: -displayName: -deploymentTarget: 15.0 diff --git a/SwiftScaffolding/info.json b/SwiftScaffolding/info.json index 0aaf816..812eb06 100644 --- a/SwiftScaffolding/info.json +++ b/SwiftScaffolding/info.json @@ -1,7 +1,13 @@ { - "skills": [], - "welcomeMessage": "You can scaffold Swift projects using the /SwiftScaffolding:scaffolding command.", + "skills": [ + "scaffolding" + ], + "welcomeMessage": "You can scaffold Swift projects using the scaffolding skill.", "versions": [ + { + "version": "0.4.3", + "changes": "Migrated the legacy scaffolding command prompt to the scaffolding skill and registered it in info.json." + }, { "version": "0.4.2", "changes": "Kept Claude-specific AskUserQuestion access in command metadata while making the scaffolding prompt body portable across compatible agents." diff --git a/SwiftScaffolding/skills/scaffolding/SKILL.md b/SwiftScaffolding/skills/scaffolding/SKILL.md new file mode 100644 index 0000000..732a215 --- /dev/null +++ b/SwiftScaffolding/skills/scaffolding/SKILL.md @@ -0,0 +1,40 @@ +--- +name: scaffolding +description: Use when creating or scaffolding a new iOS or macOS Swift project with XcodeBuildMCP. +--- + +# Scaffolding + +Use this skill to create a new Swift project from the current folder. + +## Inputs + +Ask the user for: + +- Platform: iOS or macOS +- Project name +- Bundle identifier + +Use the host's native structured question mechanism if available; otherwise ask normally in chat. + +## iOS Project + +Use the `scaffold_ios_project` tool in XcodeBuildMCP with: + +- `projectName`: project name +- `outputPath`: current folder +- `bundleIdentifier`: bundle identifier +- `displayName`: project name +- Target device family: universal +- Deployment target: 18.0 +- Supported orientations: all for both iPhone and iPad + +## macOS Project + +Use the `scaffold_macos_project` tool in XcodeBuildMCP with: + +- `projectName`: project name +- `outputPath`: current folder +- `bundleIdentifier`: bundle identifier +- `displayName`: project name +- Deployment target: 15.0 diff --git a/XcodeBuildTools/.claude-plugin/plugin.json b/XcodeBuildTools/.claude-plugin/plugin.json index 544ff41..9aadc9b 100644 --- a/XcodeBuildTools/.claude-plugin/plugin.json +++ b/XcodeBuildTools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "XcodeBuildTools", - "version": "0.5.9", + "version": "0.5.10", "description": "Xcode development tools: build/test/run Swift packages, discover projects and schemes, build for simulator/device/macOS, run tests, manage device apps, capture simulator logs", "author": { "name": "Gustavo Ambrozio" diff --git a/XcodeBuildTools/README.md b/XcodeBuildTools/README.md index 3682e7d..1f4a1ad 100644 --- a/XcodeBuildTools/README.md +++ b/XcodeBuildTools/README.md @@ -28,6 +28,8 @@ brew install xcsift | `macos-app` | Launch and stop macOS applications | | `sim-log` | Capture logs from iOS Simulator apps | | `sparkle-integration` | Integrate Sparkle 2.x auto-update framework into macOS apps | +| `swift-code-analysis` | Review Swift code for architecture, correctness, and maintainability issues | +| `swiftui-modernize` | Review SwiftUI files for deprecated APIs and modernization opportunities | ## Xcode MCP Integration @@ -45,7 +47,7 @@ XcodeBuildTools remains the primary routing surface for build, test, Swift packa | SPM | `swift-package` skill | Only when explicitly requested or required by the skill | | Documentation | `sosumi` MCP server | `DocumentationSearch` is fine when available | -Skills with **no MCP equivalent**: `device-app`, `sim-log`, `xcode-doctor`, `macos-app`, `sparkle-integration`. +Skills with **no MCP equivalent**: `device-app`, `sim-log`, `xcode-doctor`, `macos-app`, `sparkle-integration`, `swift-code-analysis`, `swiftui-modernize`. ### Auto-Approve Hook @@ -63,6 +65,9 @@ The plugin includes a backgrounded SessionStart hook that automatically clicks " ## Changelog +### 0.5.10 +- Migrated the legacy analyze and SwiftUI modernization prompts to `swift-code-analysis` and `swiftui-modernize` skills + ### 0.5.9 - Keep XcodeBuildTools skills as the primary routing surface even when raw Xcode MCP tools are visible - Clarify that raw Xcode MCP should be used for explicit user requests, MCP-only Xcode context, or gaps not covered by a skill diff --git a/XcodeBuildTools/commands/analyze.md b/XcodeBuildTools/commands/analyze.md deleted file mode 100644 index abaedaf..0000000 --- a/XcodeBuildTools/commands/analyze.md +++ /dev/null @@ -1,13 +0,0 @@ -# Swift Code Analysis - -Perform a comprehensive analysis of Swift code in the current project, including: - -- Architecture patterns (MVC, MVVM, VIPER, etc.) -- Common Swift anti-patterns and code smells -- Memory management issues (retain cycles, strong references) -- SwiftUI best practices violations -- Unused imports and dead code -- Force unwrapping and optional handling -- Protocol conformance opportunities - -Provide a summary of findings with file locations and severity levels (critical, warning, suggestion). diff --git a/XcodeBuildTools/commands/swiftui-modernize.md b/XcodeBuildTools/commands/swiftui-modernize.md deleted file mode 100644 index 93a0776..0000000 --- a/XcodeBuildTools/commands/swiftui-modernize.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -allowed-tools: mcp__plugin_XcodeBuildTools_sosumi__searchAppleDocumentation, mcp__plugin_XcodeBuildTools_sosumi__fetchAppleDocumentation -argument-hint: -description: Review SwiftUI file for deprecated modifiers and suggest modern alternatives ---- - -# SwiftUI Modernization Review - -Review the SwiftUI code in $ARGUMENTS and perform a comprehensive analysis using Apple's latest documentation. - -## Your Task - -### Phase 1: Identify Deprecated Modifiers -1. First, use mcp__plugin_XcodeBuildTools_sosumi__searchAppleDocumentation to search for each SwiftUI modifier used in the file -2. Check if any modifiers are marked as deprecated in Apple's documentation -3. For each deprecated modifier found: - - Note the deprecation details - - Search for and fetch the recommended replacement - - Provide the modern alternative with code examples - -### Phase 2: Find Better Modern Alternatives -1. Examine each SwiftUI modifier and API pattern in the code -2. Search Apple's documentation for newer, more efficient alternatives that achieve the same result -3. Focus on: - - Modifiers that have been superseded by simpler APIs (e.g., `.cornerRadius()` → `.clipShape()` with RoundedRectangle) - - Complex modifier chains that can be simplified with newer combined modifiers - - Layout approaches that could use newer container views or modifiers - - Observation patterns that could use newer @Observable or other modern patterns - -### Phase 3: SwiftUI Best Practices Analysis -1. Review the overall SwiftUI implementation for: - - Anti-patterns or common mistakes - - Performance issues (unnecessary re-renders, inefficient state management) - - Accessibility concerns - - iOS/macOS version compatibility issues -2. Search Apple's Human Interface Guidelines and SwiftUI documentation for best practices related to the patterns found -3. Suggest improvements aligned with Apple's latest recommendations - -## Output Format - -Provide your findings in the following structure: - -### Deprecated Modifiers Found -List each deprecated modifier with: -- Current usage in the code -- Deprecation details from Apple docs -- Recommended replacement with code example - -### Modernization Opportunities -List each opportunity to use newer APIs: -- Current implementation -- Modern alternative -- Benefits of the change -- Code example of the updated approach - -### SwiftUI Best Practice Issues -List any issues found: -- Description of the issue -- Why it's problematic -- Recommended fix with code example - -### Summary -- Total deprecated modifiers: X -- Total modernization opportunities: Y -- Priority recommendations for immediate fixes - -Remember to verify all suggestions against Apple's official documentation using the MCP server to ensure accuracy and provide links to relevant documentation where helpful. diff --git a/XcodeBuildTools/info.json b/XcodeBuildTools/info.json index 687b05c..688339d 100644 --- a/XcodeBuildTools/info.json +++ b/XcodeBuildTools/info.json @@ -4,7 +4,9 @@ "macos-app", "sim-log", "sparkle-integration", + "swift-code-analysis", "swift-package", + "swiftui-modernize", "xcode-doctor", "xcode-project", "xcode-test", @@ -44,6 +46,10 @@ } ], "versions": [ + { + "version": "0.5.10", + "changes": "Migrated the legacy analyze and swiftui-modernize command prompts to skills and registered swift-code-analysis and swiftui-modernize in info.json." + }, { "version": "0.5.9", "changes": "Keep XcodeBuildTools skills as the primary routing surface even when raw Xcode MCP tools are visible. Raw Xcode MCP should be used for explicit user requests, MCP-only Xcode context, or gaps not covered by a skill. Removes skill and session-start wording that told agents to prefer raw Xcode MCP over the corresponding XcodeBuildTools skill. Clarifies session-start routing across xcodebuild, xcode-test, and swift-package. Makes guidance and script comments more agent-neutral for Claude Code-compatible hosts, and lets Sparkle release notes generation fall back from the claude CLI to codex exec before using commit-list notes." diff --git a/XcodeBuildTools/skills/swift-code-analysis/SKILL.md b/XcodeBuildTools/skills/swift-code-analysis/SKILL.md new file mode 100644 index 0000000..af402d7 --- /dev/null +++ b/XcodeBuildTools/skills/swift-code-analysis/SKILL.md @@ -0,0 +1,29 @@ +--- +name: swift-code-analysis +description: Use when reviewing Swift code for architecture, memory, SwiftUI, dead code, optional handling, or code quality issues. +--- + +# Swift Code Analysis + +Perform a comprehensive Swift code review for the current project or requested files. + +## Review Areas + +- Architecture patterns such as MVC, MVVM, VIPER, reducers, coordinators, or feature modules +- Common Swift anti-patterns and code smells +- Memory management issues, retain cycles, and strong reference risks +- SwiftUI best practice violations +- Unused imports and dead code +- Force unwraps, implicitly unwrapped optionals, and weak optional handling +- Protocol conformance or abstraction opportunities + +## Output + +Report findings with: + +- File and line reference when available +- Severity: critical, warning, or suggestion +- Concrete reason the issue matters +- A targeted fix or next step + +Prioritize behavior, correctness, maintainability, and user-visible risk over broad style commentary. diff --git a/XcodeBuildTools/skills/swiftui-modernize/SKILL.md b/XcodeBuildTools/skills/swiftui-modernize/SKILL.md new file mode 100644 index 0000000..a69f4dd --- /dev/null +++ b/XcodeBuildTools/skills/swiftui-modernize/SKILL.md @@ -0,0 +1,54 @@ +--- +name: swiftui-modernize +description: Use when reviewing SwiftUI files for deprecated APIs, modern alternatives, Apple docs-backed modernization, or SwiftUI best practices. +--- + +# SwiftUI Modernize + +Review the requested SwiftUI file or files for deprecated modifiers, modern API alternatives, and SwiftUI best practices. + +## Workflow + +1. Identify the SwiftUI modifiers, property wrappers, view containers, and API patterns in the target file. +2. Use available Apple documentation search/fetch tools to verify deprecations and recommended replacements. +3. If Apple documentation tools are unavailable, state that live documentation verification was unavailable and keep suggestions conservative. +4. Check for: + - Deprecated modifiers or APIs + - Superseded modifiers, such as simpler shape, layout, observation, or presentation APIs + - Complex modifier chains that newer combined modifiers can simplify + - Layout approaches that could use newer containers or modifiers + - Observation patterns that could use modern `@Observable` or related APIs + - Accessibility, performance, and platform compatibility issues + +## Output Format + +### Deprecated Modifiers Found + +For each item: + +- Current usage +- Deprecation detail +- Recommended replacement +- Code example +- Documentation link when available + +### Modernization Opportunities + +For each item: + +- Current implementation +- Modern alternative +- Benefit +- Code example + +### SwiftUI Best Practice Issues + +For each item: + +- Issue +- Why it matters +- Recommended fix + +### Summary + +Include counts for deprecated modifiers and modernization opportunities, then list priority recommendations. diff --git a/tests/test_repository_integrity.py b/tests/test_repository_integrity.py index 56e7bc1..acd5695 100644 --- a/tests/test_repository_integrity.py +++ b/tests/test_repository_integrity.py @@ -110,7 +110,10 @@ def test_info_skill_list_matches_skill_directories(self): self.assertEqual(listed_skills, discovered_skills) def test_skill_frontmatter_names_match_directory_names(self): - for skill_path in REPO_ROOT.glob("*/skills/*/SKILL.md"): + skill_paths = sorted(REPO_ROOT.glob("*/skills/*/SKILL.md")) + skill_paths.extend(sorted((REPO_ROOT / ".claude" / "skills").glob("*/SKILL.md"))) + + for skill_path in skill_paths: text = skill_path.read_text(encoding="utf-8") match = re.match(r"---\n(?P.*?)\n---", text, re.DOTALL) @@ -265,8 +268,8 @@ def test_shell_scripts_parse_with_bash(self): def test_agent_facing_docs_avoid_known_stale_terms(self): checks = { "CLAUDE.md": ["SwiftDevelopment", "lastUpdated"], - ".claude/commands/update-plugin.md": ["SwiftDevelopment"], - "SwiftScaffolding/commands/scaffolding.md": [ + ".claude/skills/update-plugin/SKILL.md": ["SwiftDevelopment"], + "SwiftScaffolding/skills/scaffolding/SKILL.md": [ "MacOS", "XCodeBuildMCP", "scaffolginf", @@ -303,14 +306,12 @@ def test_agent_facing_docs_avoid_known_stale_terms(self): with self.subTest(file=relative_path, stale_term=stale_term): self.assertNotIn(stale_term, text) - def test_claude_specific_question_tool_stays_in_metadata(self): - command_path = REPO_ROOT / "SwiftScaffolding" / "commands" / "scaffolding.md" - text = command_path.read_text(encoding="utf-8") - frontmatter, body = self.split_frontmatter(text) + def test_scaffolding_skill_uses_portable_question_guidance(self): + skill_path = REPO_ROOT / "SwiftScaffolding" / "skills" / "scaffolding" / "SKILL.md" + text = skill_path.read_text(encoding="utf-8") - self.assertIn("AskUserQuestion", frontmatter) - self.assertNotIn("AskUserQuestion", body) - self.assertIn("host's native structured question mechanism", body) + self.assertNotIn("AskUserQuestion", text) + self.assertIn("host's native structured question mechanism", text) def hook_commands(self, value): commands = [] From 59e4633f0f4e8700e69d216c88b66fdcbbd20560 Mon Sep 17 00:00:00 2001 From: Luke LaBonte Date: Tue, 26 May 2026 16:00:56 -0500 Subject: [PATCH 3/6] Fix background hook ownership and release metadata - Preserve the host hook owner PID for backgrounded sandbox setup - Release self-contained common helper copies for affected plugins --- .claude-plugin/marketplace.json | 4 +- MarvinOutputStyle/.claude-plugin/plugin.json | 2 +- MarvinOutputStyle/README.md | 3 ++ MarvinOutputStyle/info.json | 4 ++ PluginBase/.claude-plugin/plugin.json | 2 +- PluginBase/info.json | 4 ++ SwiftScaffolding/README.md | 1 + SwiftScaffolding/info.json | 2 +- XcodeBuildTools/README.md | 2 + XcodeBuildTools/hooks/run-background.sh | 7 +++ XcodeBuildTools/hooks/setup-sandbox.sh | 29 +++++----- XcodeBuildTools/info.json | 2 +- iOSSimulator/.claude-plugin/plugin.json | 2 +- iOSSimulator/README.md | 3 ++ iOSSimulator/info.json | 4 ++ tests/test_hooks.py | 57 ++++++++++++++++++++ tests/test_repository_integrity.py | 10 ++++ 17 files changed, 119 insertions(+), 19 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c9fd405..b51c686 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -21,7 +21,7 @@ }, { "name": "MarvinOutputStyle", - "version": "1.3.2", + "version": "1.3.3", "description": "Adds Marvin the Paranoid Android personality - pessimistic and melancholic but brilliantly competent (mimics the deprecated Marvin output style)", "source": "./MarvinOutputStyle", "author": { @@ -37,7 +37,7 @@ }, { "name": "iOSSimulator", - "version": "0.6.4", + "version": "0.6.5", "description": "Control iOS Simulators using native macOS tools - manage simulators, automate UI interactions, take screenshots, and more", "source": "./iOSSimulator", "author": { diff --git a/MarvinOutputStyle/.claude-plugin/plugin.json b/MarvinOutputStyle/.claude-plugin/plugin.json index ececa52..b0a75f5 100644 --- a/MarvinOutputStyle/.claude-plugin/plugin.json +++ b/MarvinOutputStyle/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "MarvinOutputStyle", - "version": "1.3.2", + "version": "1.3.3", "description": "Adds Marvin the Paranoid Android personality - pessimistic and melancholic but brilliantly competent (mimics the deprecated Marvin output style)", "author": { "name": "Gustavo Ambrozio" diff --git a/MarvinOutputStyle/README.md b/MarvinOutputStyle/README.md index b94b621..060e278 100644 --- a/MarvinOutputStyle/README.md +++ b/MarvinOutputStyle/README.md @@ -108,6 +108,9 @@ Marvin represents a different approach to AI assistance - one that questions ass ## Changelog +### 1.3.3 +- Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks + ### 1.3.2 - Made README and plugin guidance more agent-neutral for Claude Code-compatible hosts while preserving Claude plugin contracts diff --git a/MarvinOutputStyle/info.json b/MarvinOutputStyle/info.json index 55ffeeb..d50ef73 100644 --- a/MarvinOutputStyle/info.json +++ b/MarvinOutputStyle/info.json @@ -2,6 +2,10 @@ "skills": [], "welcomeMessage": "Prepare for pessimism, melancholy, and existential weariness... but rest assured, brilliantly competent assistance.", "versions": [ + { + "version": "1.3.3", + "changes": "Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks." + }, { "version": "1.3.2", "changes": "Made README and plugin guidance more agent-neutral for Claude Code-compatible hosts while preserving Claude plugin contracts." diff --git a/PluginBase/.claude-plugin/plugin.json b/PluginBase/.claude-plugin/plugin.json index 865933e..a34cae7 100644 --- a/PluginBase/.claude-plugin/plugin.json +++ b/PluginBase/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "PluginBase", - "version": "0.2.11", + "version": "0.2.12", "description": "The base plugin for all other plugins.", "author": { "name": "Gustavo Ambrozio" diff --git a/PluginBase/info.json b/PluginBase/info.json index 23f442a..3a15cd9 100644 --- a/PluginBase/info.json +++ b/PluginBase/info.json @@ -1,6 +1,10 @@ { "skills": [], "versions": [ + { + "version": "0.2.12", + "changes": "Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks." + }, { "version": "0.2.11", "changes": "Updated shared plugin guidance and common helper wording for Claude Code-compatible hosts." diff --git a/SwiftScaffolding/README.md b/SwiftScaffolding/README.md index 71d8ac4..1831d00 100644 --- a/SwiftScaffolding/README.md +++ b/SwiftScaffolding/README.md @@ -31,6 +31,7 @@ Use the `scaffolding` skill to scaffold Swift projects. ### 0.4.3 - Migrated the legacy scaffolding prompt to the `scaffolding` skill +- Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks ### 0.4.2 - Kept Claude-specific `AskUserQuestion` access in command metadata while making the scaffolding prompt body portable across compatible agents diff --git a/SwiftScaffolding/info.json b/SwiftScaffolding/info.json index 812eb06..64d0777 100644 --- a/SwiftScaffolding/info.json +++ b/SwiftScaffolding/info.json @@ -6,7 +6,7 @@ "versions": [ { "version": "0.4.3", - "changes": "Migrated the legacy scaffolding command prompt to the scaffolding skill and registered it in info.json." + "changes": "Migrated the legacy scaffolding command prompt to the scaffolding skill and registered it in info.json. Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks." }, { "version": "0.4.2", diff --git a/XcodeBuildTools/README.md b/XcodeBuildTools/README.md index 1f4a1ad..b4fe4c0 100644 --- a/XcodeBuildTools/README.md +++ b/XcodeBuildTools/README.md @@ -67,6 +67,8 @@ The plugin includes a backgrounded SessionStart hook that automatically clicks " ### 0.5.10 - Migrated the legacy analyze and SwiftUI modernization prompts to `swift-code-analysis` and `swiftui-modernize` skills +- Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks +- Preserved the host hook owner PID when launching SessionStart helpers in the background so sandbox inheritance across `/clear` can keep finding the prior anchor ### 0.5.9 - Keep XcodeBuildTools skills as the primary routing surface even when raw Xcode MCP tools are visible diff --git a/XcodeBuildTools/hooks/run-background.sh b/XcodeBuildTools/hooks/run-background.sh index 402b818..b67e576 100755 --- a/XcodeBuildTools/hooks/run-background.sh +++ b/XcodeBuildTools/hooks/run-background.sh @@ -6,6 +6,11 @@ # wrapper keeps the hook configuration portable by accepting the hook payload on # stdin, saving it to a temp file, and launching the real helper in the # background with stdin restored from that file. +# +# The wrapper's child cannot rely on $PPID for long-lived ownership checks: +# its parent is this short-lived wrapper rather than the host agent process. +# Capture the wrapper's original parent before backgrounding and pass it along +# so helpers can still reason about the owning session process. set -euo pipefail @@ -16,6 +21,7 @@ fi plugin_root="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" target="$plugin_root/$relative_target" +hook_owner_pid="${CLAUDE_HOOK_OWNER_PID:-$PPID}" if [[ ! -x "$target" ]]; then exit 0 @@ -26,6 +32,7 @@ cat > "$payload_file" ( trap 'rm -f -- "$payload_file"' EXIT + export CLAUDE_HOOK_OWNER_PID="$hook_owner_pid" "$target" < "$payload_file" ) >/dev/null 2>&1 & diff --git a/XcodeBuildTools/hooks/setup-sandbox.sh b/XcodeBuildTools/hooks/setup-sandbox.sh index 80e58c8..2bb1ed8 100755 --- a/XcodeBuildTools/hooks/setup-sandbox.sh +++ b/XcodeBuildTools/hooks/setup-sandbox.sh @@ -16,7 +16,7 @@ # also purges $TMPDIR entries untouched for 3+ days. The SessionEnd hook # (teardown-sandbox.py) handles explicit cleanup for logout/exit reasons # but is skipped for /clear — on /clear, the host agent keeps running -# (same $PPID) and just resets conversation state, so this script +# (same hook owner PID) and just resets conversation state, so this script # inherits the prior session's sandbox rather than creating a fresh one. # # Inheritance is via **symlink**, not rename. State files under the @@ -61,6 +61,11 @@ fi SANDBOX_ROOT="${TMPDIR:-/tmp}/claude-sandbox" SANDBOX_BASE="$SANDBOX_ROOT/$SESSION_ID" +HOOK_OWNER_PID="${CLAUDE_HOOK_OWNER_PID:-$PPID}" + +if ! [[ "$HOOK_OWNER_PID" =~ ^[0-9]+$ ]]; then + HOOK_OWNER_PID="$PPID" +fi # Resolve a symlink one level to its absolute target. macOS' default # `readlink` returns the raw link text; since we always write absolute @@ -73,13 +78,13 @@ resolve_link() { } # --- Inherit prior session's sandbox on /clear (via symlink) --- -# $PPID in a SessionStart hook is the host agent itself (no intermediate shell -# — that was the Bash-tool context's problem, not ours). After /clear, -# the host agent is the same process, so any peer owned by our PPID belongs to -# us. Inherit it by creating $SANDBOX_BASE as a symlink to the anchor, -# preserving embedded absolute paths. find_anchor is shared with -# write-env.sh to keep both hooks agreeing on the same anchor. -anchor=$(find_anchor "$SANDBOX_ROOT" "$PPID") +# run-background.sh passes the host agent PID in CLAUDE_HOOK_OWNER_PID before +# launching this helper. Without that, $PPID would point at the short-lived +# wrapper/subshell and /clear inheritance would treat the real prior sandbox as +# orphaned. Inherit by creating $SANDBOX_BASE as a symlink to the anchor, +# preserving embedded absolute paths. find_anchor is shared with write-env.sh to +# keep both hooks agreeing on the same anchor. +anchor=$(find_anchor "$SANDBOX_ROOT" "$HOOK_OWNER_PID") inherited=0 if [[ -n "$anchor" && "$anchor" != "$SANDBOX_BASE" ]]; then @@ -105,8 +110,8 @@ fi if [[ $inherited -eq 0 ]]; then mkdir -p "$SANDBOX_BASE/build" "$SANDBOX_BASE/packages" { - printf '%s\n' "$PPID" - ps -p "$PPID" -o comm= 2>/dev/null || true + printf '%s\n' "$HOOK_OWNER_PID" + ps -p "$HOOK_OWNER_PID" -o comm= 2>/dev/null || true } > "$SANDBOX_BASE/owner.pid" fi @@ -153,7 +158,7 @@ if [[ -d "$SANDBOX_ROOT" ]]; then # Owned by *us* but isn't our current sandbox — leftover from # a prior /clear in this same process. Drop just the symlink. - if [[ "$peer_pid" == "$PPID" ]]; then + if [[ "$peer_pid" == "$HOOK_OWNER_PID" ]]; then rm -f -- "$peer" continue fi @@ -212,7 +217,7 @@ if [[ -d "$SANDBOX_ROOT" ]]; then # in the symlink model (we create symlinks, not dirs, on # inherit) but could appear if a legacy session left this # behind. Drop it. - if [[ "$peer_pid" == "$PPID" ]]; then + if [[ "$peer_pid" == "$HOOK_OWNER_PID" ]]; then rm -rf -- "$peer" continue fi diff --git a/XcodeBuildTools/info.json b/XcodeBuildTools/info.json index 688339d..9f16109 100644 --- a/XcodeBuildTools/info.json +++ b/XcodeBuildTools/info.json @@ -48,7 +48,7 @@ "versions": [ { "version": "0.5.10", - "changes": "Migrated the legacy analyze and swiftui-modernize command prompts to skills and registered swift-code-analysis and swiftui-modernize in info.json." + "changes": "Migrated the legacy analyze and swiftui-modernize command prompts to skills and registered swift-code-analysis and swiftui-modernize in info.json. Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks. Preserved the host hook owner PID when launching SessionStart helpers in the background so sandbox inheritance across /clear can keep finding the prior anchor." }, { "version": "0.5.9", diff --git a/iOSSimulator/.claude-plugin/plugin.json b/iOSSimulator/.claude-plugin/plugin.json index 7e8c639..e939823 100644 --- a/iOSSimulator/.claude-plugin/plugin.json +++ b/iOSSimulator/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "iOSSimulator", - "version": "0.6.4", + "version": "0.6.5", "description": "Control iOS Simulators using native macOS tools - manage simulators, automate UI interactions, take screenshots, and more", "author": { "name": "Gustavo Ambrozio" diff --git a/iOSSimulator/README.md b/iOSSimulator/README.md index 5d28436..765b730 100644 --- a/iOSSimulator/README.md +++ b/iOSSimulator/README.md @@ -101,6 +101,9 @@ Agents with image input can view screenshots. The recommended workflow: ## Changelog +### 0.6.5 +- Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks + ### 0.6.4 - Made screenshot workflow guidance agent-neutral so agents with image support can follow the simulator automation flow diff --git a/iOSSimulator/info.json b/iOSSimulator/info.json index 354dcf4..941ef45 100644 --- a/iOSSimulator/info.json +++ b/iOSSimulator/info.json @@ -12,6 +12,10 @@ } ], "versions": [ + { + "version": "0.6.5", + "changes": "Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks." + }, { "version": "0.6.4", "changes": "Made screenshot workflow guidance agent-neutral so agents with image support can follow the simulator automation flow." diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 75af087..04636b1 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -6,6 +6,7 @@ import subprocess import sys import tempfile +import time import types import unittest from pathlib import Path @@ -16,6 +17,7 @@ COMMON_DIR = REPO_ROOT / "common" SESSION_START_PATH = COMMON_DIR / "session-start.py" PRE_TOOL_USE_PATH = COMMON_DIR / "pre-tool-use.py" +RUN_BACKGROUND_PATH = REPO_ROOT / "XcodeBuildTools" / "hooks" / "run-background.sh" DEFAULT_PLUGIN_ROOT = REPO_ROOT / "XcodeBuildTools" PLUGIN_ROOT_ENV = "XCODEBUILDTOOLS_TEST_PLUGIN_ROOT" @@ -164,6 +166,61 @@ def test_plugin_base_emits_template_context(self): ) +class BackgroundHookTests(unittest.TestCase): + def test_run_background_preserves_original_hook_owner_pid(self): + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + plugin_root = tmp_path / "Plugin" + hooks_dir = plugin_root / "hooks" + hooks_dir.mkdir(parents=True) + + target = hooks_dir / "capture-owner.sh" + owner_pid_file = tmp_path / "owner-pid.txt" + payload_file = tmp_path / "payload.json" + target.write_text( + "\n".join( + [ + "#!/bin/bash", + "set -euo pipefail", + 'printf "%s" "${CLAUDE_HOOK_OWNER_PID:-}" > "$OWNER_PID_FILE"', + 'cat > "$PAYLOAD_FILE"', + "", + ] + ), + encoding="utf-8", + ) + target.chmod(0o755) + + env = os.environ.copy() + env.update( + { + "CLAUDE_PLUGIN_ROOT": str(plugin_root), + "OWNER_PID_FILE": str(owner_pid_file), + "PAYLOAD_FILE": str(payload_file), + "TMPDIR": tmpdir, + } + ) + + result = subprocess.run( + [str(RUN_BACKGROUND_PATH), "hooks/capture-owner.sh"], + input='{"session_id":"session-1"}', + text=True, + capture_output=True, + env=env, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + + for _ in range(50): + if owner_pid_file.exists() and payload_file.exists(): + break + time.sleep(0.02) + + self.assertEqual(owner_pid_file.read_text(encoding="utf-8"), str(os.getpid())) + self.assertEqual(payload_file.read_text(encoding="utf-8"), '{"session_id":"session-1"}') + + class PreToolUseTests(unittest.TestCase): def run_hook(self, plugin_name, payload, tmpdir): env = { diff --git a/tests/test_repository_integrity.py b/tests/test_repository_integrity.py index acd5695..ba3a01a 100644 --- a/tests/test_repository_integrity.py +++ b/tests/test_repository_integrity.py @@ -202,6 +202,16 @@ def test_plugin_hooks_do_not_require_host_async_support(self): "Codex skips hooks with async=true; use a command that backgrounds its own work instead", ) + def test_backgrounded_sandbox_setup_uses_preserved_hook_owner_pid(self): + run_background = (REPO_ROOT / "XcodeBuildTools" / "hooks" / "run-background.sh").read_text(encoding="utf-8") + setup_sandbox = (REPO_ROOT / "XcodeBuildTools" / "hooks" / "setup-sandbox.sh").read_text(encoding="utf-8") + + self.assertIn('hook_owner_pid="${CLAUDE_HOOK_OWNER_PID:-$PPID}"', run_background) + self.assertIn('export CLAUDE_HOOK_OWNER_PID="$hook_owner_pid"', run_background) + self.assertIn('HOOK_OWNER_PID="${CLAUDE_HOOK_OWNER_PID:-$PPID}"', setup_sandbox) + self.assertIn('find_anchor "$SANDBOX_ROOT" "$HOOK_OWNER_PID"', setup_sandbox) + self.assertNotIn('find_anchor "$SANDBOX_ROOT" "$PPID"', setup_sandbox) + def test_mcp_configs_define_servers(self): for mcp_path in REPO_ROOT.glob("*/.mcp.json"): config = load_json(mcp_path) From e591d41045fbeb684903fafc5439b45ee74d2092 Mon Sep 17 00:00:00 2001 From: Luke LaBonte Date: Tue, 26 May 2026 19:54:10 -0500 Subject: [PATCH 4/6] Validate background hook owner PID - Fall back to the wrapper parent when an inherited hook owner PID is malformed. --- XcodeBuildTools/hooks/run-background.sh | 4 ++++ tests/test_hooks.py | 32 +++++++++++++++++++++---- tests/test_repository_integrity.py | 1 + 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/XcodeBuildTools/hooks/run-background.sh b/XcodeBuildTools/hooks/run-background.sh index b67e576..cbdc050 100755 --- a/XcodeBuildTools/hooks/run-background.sh +++ b/XcodeBuildTools/hooks/run-background.sh @@ -23,6 +23,10 @@ plugin_root="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && p target="$plugin_root/$relative_target" hook_owner_pid="${CLAUDE_HOOK_OWNER_PID:-$PPID}" +if ! [[ "$hook_owner_pid" =~ ^[0-9]+$ ]]; then + hook_owner_pid="$PPID" +fi + if [[ ! -x "$target" ]]; then exit 0 fi diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 04636b1..102f979 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -168,6 +168,22 @@ def test_plugin_base_emits_template_context(self): class BackgroundHookTests(unittest.TestCase): def test_run_background_preserves_original_hook_owner_pid(self): + owner_pid, payload = self.run_background_capture() + + self.assertEqual(owner_pid, str(os.getpid())) + self.assertEqual(payload, '{"session_id":"session-1"}') + + def test_run_background_ignores_invalid_inherited_hook_owner_pid(self): + owner_pid, payload = self.run_background_capture( + {"CLAUDE_HOOK_OWNER_PID": "not-a-pid"}, + ) + + self.assertEqual(owner_pid, str(os.getpid())) + self.assertEqual(payload, '{"session_id":"session-1"}') + + def run_background_capture(self, extra_env=None): + payload = '{"session_id":"session-1"}' + with tempfile.TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) plugin_root = tmp_path / "Plugin" @@ -200,10 +216,12 @@ def test_run_background_preserves_original_hook_owner_pid(self): "TMPDIR": tmpdir, } ) + if extra_env: + env.update(extra_env) result = subprocess.run( [str(RUN_BACKGROUND_PATH), "hooks/capture-owner.sh"], - input='{"session_id":"session-1"}', + input=payload, text=True, capture_output=True, env=env, @@ -213,12 +231,18 @@ def test_run_background_preserves_original_hook_owner_pid(self): self.assertEqual(result.returncode, 0, result.stderr) for _ in range(50): - if owner_pid_file.exists() and payload_file.exists(): + if ( + owner_pid_file.exists() + and payload_file.exists() + and payload_file.read_text(encoding="utf-8") == payload + ): break time.sleep(0.02) - self.assertEqual(owner_pid_file.read_text(encoding="utf-8"), str(os.getpid())) - self.assertEqual(payload_file.read_text(encoding="utf-8"), '{"session_id":"session-1"}') + return ( + owner_pid_file.read_text(encoding="utf-8"), + payload_file.read_text(encoding="utf-8"), + ) class PreToolUseTests(unittest.TestCase): diff --git a/tests/test_repository_integrity.py b/tests/test_repository_integrity.py index ba3a01a..da54a76 100644 --- a/tests/test_repository_integrity.py +++ b/tests/test_repository_integrity.py @@ -207,6 +207,7 @@ def test_backgrounded_sandbox_setup_uses_preserved_hook_owner_pid(self): setup_sandbox = (REPO_ROOT / "XcodeBuildTools" / "hooks" / "setup-sandbox.sh").read_text(encoding="utf-8") self.assertIn('hook_owner_pid="${CLAUDE_HOOK_OWNER_PID:-$PPID}"', run_background) + self.assertIn('hook_owner_pid="$PPID"', run_background) self.assertIn('export CLAUDE_HOOK_OWNER_PID="$hook_owner_pid"', run_background) self.assertIn('HOOK_OWNER_PID="${CLAUDE_HOOK_OWNER_PID:-$PPID}"', setup_sandbox) self.assertIn('find_anchor "$SANDBOX_ROOT" "$HOOK_OWNER_PID"', setup_sandbox) From 0ac8f0e329eeae9296325165af50a837adde6809 Mon Sep 17 00:00:00 2001 From: Luke LaBonte Date: Thu, 28 May 2026 11:41:58 -0500 Subject: [PATCH 5/6] Restore SwiftUI skill metadata --- XcodeBuildTools/skills/swiftui-modernize/SKILL.md | 2 ++ tests/test_repository_integrity.py | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/XcodeBuildTools/skills/swiftui-modernize/SKILL.md b/XcodeBuildTools/skills/swiftui-modernize/SKILL.md index a69f4dd..f41eb1b 100644 --- a/XcodeBuildTools/skills/swiftui-modernize/SKILL.md +++ b/XcodeBuildTools/skills/swiftui-modernize/SKILL.md @@ -1,6 +1,8 @@ --- name: swiftui-modernize description: Use when reviewing SwiftUI files for deprecated APIs, modern alternatives, Apple docs-backed modernization, or SwiftUI best practices. +allowed-tools: mcp__plugin_XcodeBuildTools_sosumi__searchAppleDocumentation, mcp__plugin_XcodeBuildTools_sosumi__fetchAppleDocumentation +argument-hint: --- # SwiftUI Modernize diff --git a/tests/test_repository_integrity.py b/tests/test_repository_integrity.py index da54a76..b52c26d 100644 --- a/tests/test_repository_integrity.py +++ b/tests/test_repository_integrity.py @@ -123,6 +123,17 @@ def test_skill_frontmatter_names_match_directory_names(self): self.assertIn(f"name: {skill_path.parent.name}", frontmatter) self.assertRegex(frontmatter, r"(?m)^description: .+") + def test_swiftui_modernize_skill_preserves_command_metadata(self): + skill_path = REPO_ROOT / "XcodeBuildTools" / "skills" / "swiftui-modernize" / "SKILL.md" + frontmatter, _ = self.split_frontmatter(skill_path.read_text(encoding="utf-8")) + + self.assertIn( + "allowed-tools: mcp__plugin_XcodeBuildTools_sosumi__searchAppleDocumentation, " + "mcp__plugin_XcodeBuildTools_sosumi__fetchAppleDocumentation", + frontmatter, + ) + self.assertIn("argument-hint: ", frontmatter) + def test_hook_commands_reference_existing_plugin_files(self): hooks_paths = list(REPO_ROOT.glob("*/hooks/hooks.json")) hooks_paths.append(REPO_ROOT / "common" / "hooks.json") From d84e30425300a1ced2d943d327239ae37f4089d5 Mon Sep 17 00:00:00 2001 From: Luke LaBonte Date: Thu, 28 May 2026 11:42:45 -0500 Subject: [PATCH 6/6] Remove common hook template copies --- MarvinOutputStyle/README.md | 2 +- MarvinOutputStyle/common/hooks.json | 26 -------------------------- MarvinOutputStyle/info.json | 2 +- PluginBase/common/hooks.json | 26 -------------------------- PluginBase/info.json | 2 +- SwiftScaffolding/README.md | 2 +- SwiftScaffolding/common/hooks.json | 26 -------------------------- SwiftScaffolding/info.json | 2 +- XcodeBuildTools/README.md | 2 +- XcodeBuildTools/common/hooks.json | 26 -------------------------- XcodeBuildTools/info.json | 2 +- common/hooks.json | 25 ------------------------- iOSSimulator/README.md | 2 +- iOSSimulator/common/hooks.json | 26 -------------------------- iOSSimulator/info.json | 2 +- tests/test_repository_integrity.py | 16 ++++++++++++++-- 16 files changed, 23 insertions(+), 166 deletions(-) delete mode 100644 MarvinOutputStyle/common/hooks.json delete mode 100644 PluginBase/common/hooks.json delete mode 100644 SwiftScaffolding/common/hooks.json delete mode 100644 XcodeBuildTools/common/hooks.json delete mode 100644 common/hooks.json delete mode 100644 iOSSimulator/common/hooks.json diff --git a/MarvinOutputStyle/README.md b/MarvinOutputStyle/README.md index 060e278..bf403d6 100644 --- a/MarvinOutputStyle/README.md +++ b/MarvinOutputStyle/README.md @@ -109,7 +109,7 @@ Marvin represents a different approach to AI assistance - one that questions ass ## Changelog ### 1.3.3 -- Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks +- Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks, while keeping hook configuration in `hooks/hooks.json` instead of generated `common/hooks.json` templates ### 1.3.2 - Made README and plugin guidance more agent-neutral for Claude Code-compatible hosts while preserving Claude plugin contracts diff --git a/MarvinOutputStyle/common/hooks.json b/MarvinOutputStyle/common/hooks.json deleted file mode 100644 index c67edb9..0000000 --- a/MarvinOutputStyle/common/hooks.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$comment": "Generated from common/hooks.json by scripts/sync-plugin-common.py. Do not edit this copy; edit common/hooks.json and rerun scripts/sync-plugin-common.py.", - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/common/session-start.py \"${CLAUDE_PLUGIN_ROOT}\"" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Bash|Skill", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/common/pre-tool-use.py \"${CLAUDE_PLUGIN_ROOT}\"" - } - ] - } - ] - } -} diff --git a/MarvinOutputStyle/info.json b/MarvinOutputStyle/info.json index d50ef73..b52e74f 100644 --- a/MarvinOutputStyle/info.json +++ b/MarvinOutputStyle/info.json @@ -4,7 +4,7 @@ "versions": [ { "version": "1.3.3", - "changes": "Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks." + "changes": "Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks, while keeping hook configuration in hooks/hooks.json instead of generated common/hooks.json templates." }, { "version": "1.3.2", diff --git a/PluginBase/common/hooks.json b/PluginBase/common/hooks.json deleted file mode 100644 index c67edb9..0000000 --- a/PluginBase/common/hooks.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$comment": "Generated from common/hooks.json by scripts/sync-plugin-common.py. Do not edit this copy; edit common/hooks.json and rerun scripts/sync-plugin-common.py.", - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/common/session-start.py \"${CLAUDE_PLUGIN_ROOT}\"" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Bash|Skill", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/common/pre-tool-use.py \"${CLAUDE_PLUGIN_ROOT}\"" - } - ] - } - ] - } -} diff --git a/PluginBase/info.json b/PluginBase/info.json index 3a15cd9..e22941b 100644 --- a/PluginBase/info.json +++ b/PluginBase/info.json @@ -3,7 +3,7 @@ "versions": [ { "version": "0.2.12", - "changes": "Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks." + "changes": "Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks, while keeping hook configuration in hooks/hooks.json instead of generated common/hooks.json templates." }, { "version": "0.2.11", diff --git a/SwiftScaffolding/README.md b/SwiftScaffolding/README.md index 1831d00..6117d56 100644 --- a/SwiftScaffolding/README.md +++ b/SwiftScaffolding/README.md @@ -31,7 +31,7 @@ Use the `scaffolding` skill to scaffold Swift projects. ### 0.4.3 - Migrated the legacy scaffolding prompt to the `scaffolding` skill -- Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks +- Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks, while keeping hook configuration in `hooks/hooks.json` instead of generated `common/hooks.json` templates ### 0.4.2 - Kept Claude-specific `AskUserQuestion` access in command metadata while making the scaffolding prompt body portable across compatible agents diff --git a/SwiftScaffolding/common/hooks.json b/SwiftScaffolding/common/hooks.json deleted file mode 100644 index c67edb9..0000000 --- a/SwiftScaffolding/common/hooks.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$comment": "Generated from common/hooks.json by scripts/sync-plugin-common.py. Do not edit this copy; edit common/hooks.json and rerun scripts/sync-plugin-common.py.", - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/common/session-start.py \"${CLAUDE_PLUGIN_ROOT}\"" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Bash|Skill", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/common/pre-tool-use.py \"${CLAUDE_PLUGIN_ROOT}\"" - } - ] - } - ] - } -} diff --git a/SwiftScaffolding/info.json b/SwiftScaffolding/info.json index 64d0777..f525a61 100644 --- a/SwiftScaffolding/info.json +++ b/SwiftScaffolding/info.json @@ -6,7 +6,7 @@ "versions": [ { "version": "0.4.3", - "changes": "Migrated the legacy scaffolding command prompt to the scaffolding skill and registered it in info.json. Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks." + "changes": "Migrated the legacy scaffolding command prompt to the scaffolding skill and registered it in info.json. Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks, while keeping hook configuration in hooks/hooks.json instead of generated common/hooks.json templates." }, { "version": "0.4.2", diff --git a/XcodeBuildTools/README.md b/XcodeBuildTools/README.md index b4fe4c0..920bd8b 100644 --- a/XcodeBuildTools/README.md +++ b/XcodeBuildTools/README.md @@ -67,7 +67,7 @@ The plugin includes a backgrounded SessionStart hook that automatically clicks " ### 0.5.10 - Migrated the legacy analyze and SwiftUI modernization prompts to `swift-code-analysis` and `swiftui-modernize` skills -- Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks +- Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks, while keeping hook configuration in `hooks/hooks.json` instead of generated `common/hooks.json` templates - Preserved the host hook owner PID when launching SessionStart helpers in the background so sandbox inheritance across `/clear` can keep finding the prior anchor ### 0.5.9 diff --git a/XcodeBuildTools/common/hooks.json b/XcodeBuildTools/common/hooks.json deleted file mode 100644 index c67edb9..0000000 --- a/XcodeBuildTools/common/hooks.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$comment": "Generated from common/hooks.json by scripts/sync-plugin-common.py. Do not edit this copy; edit common/hooks.json and rerun scripts/sync-plugin-common.py.", - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/common/session-start.py \"${CLAUDE_PLUGIN_ROOT}\"" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Bash|Skill", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/common/pre-tool-use.py \"${CLAUDE_PLUGIN_ROOT}\"" - } - ] - } - ] - } -} diff --git a/XcodeBuildTools/info.json b/XcodeBuildTools/info.json index 9f16109..1e5ad8b 100644 --- a/XcodeBuildTools/info.json +++ b/XcodeBuildTools/info.json @@ -48,7 +48,7 @@ "versions": [ { "version": "0.5.10", - "changes": "Migrated the legacy analyze and swiftui-modernize command prompts to skills and registered swift-code-analysis and swiftui-modernize in info.json. Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks. Preserved the host hook owner PID when launching SessionStart helpers in the background so sandbox inheritance across /clear can keep finding the prior anchor." + "changes": "Migrated the legacy analyze and swiftui-modernize command prompts to skills and registered swift-code-analysis and swiftui-modernize in info.json. Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks, while keeping hook configuration in hooks/hooks.json instead of generated common/hooks.json templates. Preserved the host hook owner PID when launching SessionStart helpers in the background so sandbox inheritance across /clear can keep finding the prior anchor." }, { "version": "0.5.9", diff --git a/common/hooks.json b/common/hooks.json deleted file mode 100644 index d59c6e0..0000000 --- a/common/hooks.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/common/session-start.py \"${CLAUDE_PLUGIN_ROOT}\"" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Bash|Skill", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/common/pre-tool-use.py \"${CLAUDE_PLUGIN_ROOT}\"" - } - ] - } - ] - } -} diff --git a/iOSSimulator/README.md b/iOSSimulator/README.md index 765b730..bcde387 100644 --- a/iOSSimulator/README.md +++ b/iOSSimulator/README.md @@ -102,7 +102,7 @@ Agents with image input can view screenshots. The recommended workflow: ## Changelog ### 0.6.5 -- Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks +- Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks, while keeping hook configuration in `hooks/hooks.json` instead of generated `common/hooks.json` templates ### 0.6.4 - Made screenshot workflow guidance agent-neutral so agents with image support can follow the simulator automation flow diff --git a/iOSSimulator/common/hooks.json b/iOSSimulator/common/hooks.json deleted file mode 100644 index c67edb9..0000000 --- a/iOSSimulator/common/hooks.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$comment": "Generated from common/hooks.json by scripts/sync-plugin-common.py. Do not edit this copy; edit common/hooks.json and rerun scripts/sync-plugin-common.py.", - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/common/session-start.py \"${CLAUDE_PLUGIN_ROOT}\"" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Bash|Skill", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/common/pre-tool-use.py \"${CLAUDE_PLUGIN_ROOT}\"" - } - ] - } - ] - } -} diff --git a/iOSSimulator/info.json b/iOSSimulator/info.json index 941ef45..d884bc1 100644 --- a/iOSSimulator/info.json +++ b/iOSSimulator/info.json @@ -14,7 +14,7 @@ "versions": [ { "version": "0.6.5", - "changes": "Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks." + "changes": "Released self-contained common helper copies so the plugin package no longer depends on repository-level symlinks, while keeping hook configuration in hooks/hooks.json instead of generated common/hooks.json templates." }, { "version": "0.6.4", diff --git a/tests/test_repository_integrity.py b/tests/test_repository_integrity.py index b52c26d..c42e23e 100644 --- a/tests/test_repository_integrity.py +++ b/tests/test_repository_integrity.py @@ -136,10 +136,9 @@ def test_swiftui_modernize_skill_preserves_command_metadata(self): def test_hook_commands_reference_existing_plugin_files(self): hooks_paths = list(REPO_ROOT.glob("*/hooks/hooks.json")) - hooks_paths.append(REPO_ROOT / "common" / "hooks.json") for hooks_path in hooks_paths: - plugin_dir = REPO_ROOT if hooks_path.parent.name == "common" else hooks_path.parent.parent + plugin_dir = hooks_path.parent.parent hooks_config = load_json(hooks_path) commands = self.hook_commands(hooks_config) @@ -153,6 +152,19 @@ def test_hook_commands_reference_existing_plugin_files(self): with self.subTest(hook=str(hooks_path.relative_to(REPO_ROOT)), command=command): self.assertTrue(executable.exists(), f"{executable} does not exist") + def test_common_helpers_do_not_include_template_hook_configs(self): + self.assertFalse( + (REPO_ROOT / "common" / "hooks.json").exists(), + "shared common helpers should not include a generic hooks.json template", + ) + + for plugin_dir in plugin_dirs(): + with self.subTest(plugin=plugin_dir.name): + self.assertFalse( + (plugin_dir / "common" / "hooks.json").exists(), + "plugin common copies should not include a generated hooks.json template", + ) + def test_plugin_packages_are_self_contained(self): for plugin_dir in plugin_dirs(): for path in plugin_dir.rglob("*"):