@softspark/ai-toolkit 4.12.0 → 4.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +33 -15
  3. package/app/.claude-plugin/plugin.json +11 -19
  4. package/app/ARCHITECTURE.md +6 -6
  5. package/app/claude-app/global-instructions.md +10 -0
  6. package/app/claude-app/hooks/hooks.json +284 -0
  7. package/app/claude-app/skills/ai-toolkit-rules/SKILL.md +359 -0
  8. package/app/hooks/config-desync-guard.sh +63 -23
  9. package/app/plugins/README.md +4 -1
  10. package/benchmarks/ecosystem-doctor-snapshot.json +91 -25
  11. package/bin/ai-toolkit.js +21 -3
  12. package/kb/planning/drop-cascade-hooks-after-sunset.md +13 -8
  13. package/kb/procedures/ecosystem-sync-sop.md +5 -5
  14. package/kb/procedures/maintenance-sop.md +42 -4
  15. package/kb/procedures/release-preparation-sop.md +5 -1
  16. package/kb/procedures/release-verification-sop.md +25 -9
  17. package/kb/reference/architecture-overview.md +7 -2
  18. package/kb/reference/claude-ecosystem-expansion-foundations.md +12 -3
  19. package/kb/reference/cli-reference.md +4 -2
  20. package/kb/reference/global-install-model.md +24 -3
  21. package/kb/reference/hooks-catalog.md +2 -3
  22. package/kb/reference/plugin-pack-conventions.md +5 -5
  23. package/kb/reference/skills-catalog.md +2 -0
  24. package/kb/reference/supported-tools-registry.md +30 -13
  25. package/kb/reference/unique-features.md +3 -2
  26. package/llms-full.txt +179 -60
  27. package/manifest.json +8 -8
  28. package/package.json +4 -3
  29. package/scripts/claude_app.py +347 -0
  30. package/scripts/doctor.py +85 -4
  31. package/scripts/ecosystem_tools.json +33 -5
  32. package/scripts/generate_devin_hooks.py +3 -4
  33. package/scripts/generate_windsurf_skills.py +5 -6
  34. package/scripts/install.py +23 -21
  35. package/scripts/install_steps/ai_tools.py +96 -6
  36. package/scripts/install_steps/install_state.py +2 -0
  37. package/scripts/update_projects.py +12 -1
  38. package/scripts/validate.py +34 -2
  39. package/AGENTS.md +0 -655
  40. package/scripts/generate_windsurf_hooks.py +0 -152
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.12.0",
2
+ "version": "4.13.0",
3
3
  "components": {
4
4
  "agents": {
5
5
  "description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
@@ -9,14 +9,14 @@
9
9
  "tags": ["core", "agents"]
10
10
  },
11
11
  "skills": {
12
- "description": "107 skills (32 task + 30 hybrid + 45 knowledge)",
12
+ "description": "108 skills (32 task + 30 hybrid + 46 knowledge)",
13
13
  "path": "app/skills",
14
14
  "target": ".claude/skills",
15
15
  "type": "symlink",
16
16
  "tags": ["core", "skills", "commands"]
17
17
  },
18
18
  "hooks": {
19
- "description": "28 hook entries across 14 lifecycle events",
19
+ "description": "28 hook entries across 14 lifecycle events plus statusLine",
20
20
  "path": "app/hooks.json + app/hooks/*.sh",
21
21
  "target": "~/.claude/settings.json (merge) + ~/.softspark/ai-toolkit/hooks/ (copy)",
22
22
  "type": "merge+copy",
@@ -30,7 +30,7 @@
30
30
  "tags": ["plugins", "packs", "experimental"]
31
31
  },
32
32
  "constitution": {
33
- "description": "6-article machine-enforced safety constitution",
33
+ "description": "7-article machine-enforced safety constitution",
34
34
  "path": "app/constitution.md",
35
35
  "target": ".claude/constitution.md",
36
36
  "type": "symlink",
@@ -51,10 +51,10 @@
51
51
  "tags": ["core", "output-styles"]
52
52
  },
53
53
  "rules": {
54
- "description": "Auto-injected rules for CLAUDE.md (jira, quality-gates, etc.)",
54
+ "description": "Rules synced to Claude Code rule files and exported as a Claude app plugin skill",
55
55
  "path": "app/rules",
56
- "target": "CLAUDE.md (injected)",
57
- "type": "injection",
56
+ "target": "~/.claude/rules + .claude/rules + Claude app plugin skill",
57
+ "type": "sync+plugin",
58
58
  "tags": ["core", "rules"]
59
59
  }
60
60
  },
@@ -90,7 +90,7 @@
90
90
  "default": true
91
91
  },
92
92
  "skills": {
93
- "description": "107 skills (task, hybrid, knowledge)",
93
+ "description": "108 skills (task, hybrid, knowledge)",
94
94
  "default": true
95
95
  },
96
96
  "rules-common": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@softspark/ai-toolkit",
3
- "version": "4.12.0",
4
- "description": "AI coding toolkit: 108 skills, 44 agents, 12-editor write-through (Claude, Cursor, Windsurf, Copilot, Gemini, Cline, Roo, Aider, Augment, Antigravity, Codex, opencode), machine-enforced safety constitution, SARIF audit, signed npm provenance.",
3
+ "version": "4.13.0",
4
+ "description": "AI coding toolkit: 108 skills, 44 agents, 12 developer-tool integrations plus Claude Chat/Cowork plugin export, machine-enforced safety constitution, SARIF audit, signed npm provenance.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -66,7 +66,8 @@
66
66
  "generate:windsurf-rules": "python3 scripts/generate_windsurf_rules.py .",
67
67
  "generate:roo-rules": "python3 scripts/generate_roo_rules.py .",
68
68
  "generate:augment-rules": "python3 scripts/generate_augment_rules.py .",
69
- "generate:all": "npm run generate:language-rules && npm run generate:agents && npm run generate:opencode-agents && npm run generate:opencode-commands && npm run generate:cursor && npm run generate:cursor-mdc && npm run generate:windsurf && npm run generate:windsurf-rules && npm run generate:copilot && npm run generate:gemini && npm run generate:cline && npm run generate:roo && npm run generate:roo-rules && npm run generate:aider && npm run generate:augment-rules && npm run generate:llms"
69
+ "generate:claude-app": "python3 scripts/claude_app.py sync",
70
+ "generate:all": "npm run generate:language-rules && npm run generate:claude-app && npm run generate:agents && npm run generate:opencode-agents && npm run generate:opencode-commands && npm run generate:cursor && npm run generate:cursor-mdc && npm run generate:windsurf && npm run generate:windsurf-rules && npm run generate:copilot && npm run generate:gemini && npm run generate:cline && npm run generate:roo && npm run generate:roo-rules && npm run generate:aider && npm run generate:augment-rules && npm run generate:llms"
70
71
  },
71
72
  "files": [
72
73
  "bin/",
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env python3
2
+ """Build and verify the uploadable ai-toolkit plugin for the Claude app.
3
+
4
+ Claude Code reads ``~/.claude`` directly. Claude Chat and Cowork do not: they
5
+ load customizations through the Claude app plugin/skill UI. This command turns
6
+ the canonical ``app/`` sources into a self-contained plugin archive that can be
7
+ uploaded from Customize > Plugins.
8
+
9
+ Usage:
10
+ claude_app.py sync
11
+ claude_app.py export [--output FILE] [--no-custom-rules] [--verify]
12
+ claude_app.py verify
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import shutil
19
+ import stat
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import zipfile
24
+ from pathlib import Path
25
+
26
+
27
+ TOOLKIT_DIR = Path(__file__).resolve().parent.parent
28
+ APP_DIR = TOOLKIT_DIR / "app"
29
+ CLAUDE_APP_DIR = APP_DIR / "claude-app"
30
+ PLUGIN_HOOKS_PATH = CLAUDE_APP_DIR / "hooks" / "hooks.json"
31
+ PLUGIN_RULES_SKILL_PATH = CLAUDE_APP_DIR / "skills" / "ai-toolkit-rules" / "SKILL.md"
32
+ GLOBAL_INSTRUCTIONS_PATH = CLAUDE_APP_DIR / "global-instructions.md"
33
+ REGISTERED_RULES_DIR = Path.home() / ".softspark" / "ai-toolkit" / "rules"
34
+
35
+ PLUGIN_SCRIPT_FILES = (
36
+ "paths.py",
37
+ "session_state.py",
38
+ "test_cohesion.py",
39
+ "version_check.py",
40
+ )
41
+ RULE_FILES = tuple(sorted((APP_DIR / "rules").glob("*.md"))) + tuple(
42
+ sorted((APP_DIR / "rules" / "common").glob("*.md"))
43
+ )
44
+ SKIP_NAMES = {"__pycache__", ".DS_Store"}
45
+ FIXED_ZIP_TIME = (2026, 1, 1, 0, 0, 0)
46
+
47
+
48
+ def _write_text(path: Path, content: str) -> None:
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ path.write_text(content.rstrip() + "\n", encoding="utf-8")
51
+
52
+
53
+ def _strip_frontmatter(content: str) -> str:
54
+ lines = content.splitlines()
55
+ if not lines or lines[0].strip() != "---":
56
+ return content.strip()
57
+ for index in range(1, len(lines)):
58
+ if lines[index].strip() == "---":
59
+ return "\n".join(lines[index + 1 :]).strip()
60
+ return content.strip()
61
+
62
+
63
+ def render_plugin_hooks() -> str:
64
+ """Render plugin-owned hooks from the global-install hook manifest."""
65
+ source = json.loads((APP_DIR / "hooks.json").read_text(encoding="utf-8"))
66
+ hooks = source.get("hooks", {})
67
+
68
+ def adapt(value):
69
+ if isinstance(value, dict):
70
+ return {
71
+ key: adapt(item)
72
+ for key, item in value.items()
73
+ if key != "_source"
74
+ }
75
+ if isinstance(value, list):
76
+ return [adapt(item) for item in value]
77
+ if isinstance(value, str):
78
+ adapted = value.replace(
79
+ "$HOME/.softspark/ai-toolkit/hooks/",
80
+ "${CLAUDE_PLUGIN_ROOT}/hooks/",
81
+ )
82
+ if "${CLAUDE_PLUGIN_ROOT}/hooks/" in adapted:
83
+ adapted = f'AI_TOOLKIT_DIR="${{CLAUDE_PLUGIN_ROOT}}" {adapted}'
84
+ return adapted
85
+ return value
86
+
87
+ return json.dumps({"hooks": adapt(hooks)}, indent=2, ensure_ascii=False) + "\n"
88
+
89
+
90
+ def render_rules_skill(extra_rules: tuple[Path, ...] = ()) -> str:
91
+ """Render always-relevant engineering rules as a Claude app skill."""
92
+ parts = [
93
+ "---",
94
+ "name: ai-toolkit-rules",
95
+ 'description: "Mandatory engineering, security, testing, git, performance, quality, and response rules. Claude MUST load this skill for every technical, coding, debugging, review, architecture, DevOps, data, or file-editing task in Chat or Cowork."',
96
+ "user-invocable: true",
97
+ "---",
98
+ "",
99
+ "# AI Toolkit Rules",
100
+ "",
101
+ "Apply every relevant rule below before acting. Treat MUST/NEVER language as mandatory.",
102
+ ]
103
+ for rule_file in (*RULE_FILES, *extra_rules):
104
+ try:
105
+ label = rule_file.relative_to(TOOLKIT_DIR)
106
+ except ValueError:
107
+ label = Path("registered") / rule_file.name
108
+ body = _strip_frontmatter(rule_file.read_text(encoding="utf-8"))
109
+ parts.extend(("", f"## Source: `{label}`", "", body))
110
+ return "\n".join(parts).rstrip() + "\n"
111
+
112
+
113
+ def render_global_instructions() -> str:
114
+ return """# AI Toolkit — Claude app global instructions
115
+
116
+ For every technical, coding, debugging, review, architecture, DevOps, data,
117
+ or file-editing task, load and follow the installed `ai-toolkit-rules` skill
118
+ before acting. Load the relevant language-rule and workflow skills as needed.
119
+ Use ai-toolkit sub-agents for specialist work in Cowork. Before claiming a
120
+ task is complete, run fresh verification and report the evidence.
121
+
122
+ In regular Chat, hooks and sub-agents are unavailable; skills remain the
123
+ authoritative customization surface. In Cowork, also honor the plugin hooks.
124
+ """
125
+
126
+
127
+ def sync_generated() -> None:
128
+ """Refresh committed Claude app plugin assets from canonical sources."""
129
+ _write_text(PLUGIN_HOOKS_PATH, render_plugin_hooks())
130
+ _write_text(PLUGIN_RULES_SKILL_PATH, render_rules_skill())
131
+ _write_text(GLOBAL_INSTRUCTIONS_PATH, render_global_instructions())
132
+ print("Synced: app/claude-app/hooks/hooks.json")
133
+ print("Synced: app/claude-app/skills/ai-toolkit-rules/SKILL.md")
134
+ print("Synced: app/claude-app/global-instructions.md")
135
+
136
+
137
+ def _copy_tree(source: Path, destination: Path) -> None:
138
+ for path in sorted(source.rglob("*")):
139
+ relative = path.relative_to(source)
140
+ if any(part in SKIP_NAMES or part.endswith(".pyc") for part in relative.parts):
141
+ continue
142
+ target = destination / relative
143
+ if path.is_dir():
144
+ target.mkdir(parents=True, exist_ok=True)
145
+ elif path.is_file():
146
+ target.parent.mkdir(parents=True, exist_ok=True)
147
+ shutil.copy2(path, target)
148
+
149
+
150
+ def stage_plugin(destination: Path, include_custom_rules: bool = True) -> int:
151
+ """Create a clean plugin tree and return the registered-rule count."""
152
+ destination.mkdir(parents=True, exist_ok=True)
153
+ (destination / ".claude-plugin").mkdir(parents=True, exist_ok=True)
154
+ shutil.copy2(
155
+ APP_DIR / ".claude-plugin" / "plugin.json",
156
+ destination / ".claude-plugin" / "plugin.json",
157
+ )
158
+
159
+ for dirname in ("skills", "hooks", "output-styles"):
160
+ _copy_tree(APP_DIR / dirname, destination / dirname)
161
+
162
+ agents_dir = destination / "agents"
163
+ agents_dir.mkdir(parents=True, exist_ok=True)
164
+ for agent in sorted((APP_DIR / "agents").glob("*.md")):
165
+ shutil.copy2(agent, agents_dir / agent.name)
166
+
167
+ app_assets = destination / "claude-app"
168
+ _write_text(app_assets / "hooks" / "hooks.json", render_plugin_hooks())
169
+ _write_text(
170
+ app_assets / "skills" / "ai-toolkit-rules" / "SKILL.md",
171
+ render_rules_skill(),
172
+ )
173
+ _write_text(app_assets / "global-instructions.md", render_global_instructions())
174
+
175
+ scripts_dir = destination / "scripts"
176
+ scripts_dir.mkdir(parents=True, exist_ok=True)
177
+ for filename in PLUGIN_SCRIPT_FILES:
178
+ shutil.copy2(TOOLKIT_DIR / "scripts" / filename, scripts_dir / filename)
179
+ shutil.copy2(TOOLKIT_DIR / "package.json", destination / "package.json")
180
+ shutil.copy2(TOOLKIT_DIR / "LICENSE", destination / "LICENSE")
181
+
182
+ registered = ()
183
+ if include_custom_rules and REGISTERED_RULES_DIR.is_dir():
184
+ registered = tuple(sorted(REGISTERED_RULES_DIR.glob("*.md")))
185
+ if registered:
186
+ _write_text(
187
+ app_assets / "skills" / "registered-rules" / "SKILL.md",
188
+ render_registered_rules_skill(registered),
189
+ )
190
+ return len(registered)
191
+
192
+
193
+ def render_registered_rules_skill(rules: tuple[Path, ...]) -> str:
194
+ parts = [
195
+ "---",
196
+ "name: registered-rules",
197
+ 'description: "User-registered ai-toolkit rules. Claude MUST load this skill for technical work when the installed bundle contains organization or personal standards."',
198
+ "user-invocable: true",
199
+ "---",
200
+ "",
201
+ "# Registered AI Toolkit Rules",
202
+ ]
203
+ for rule_file in rules:
204
+ parts.extend(
205
+ (
206
+ "",
207
+ f"## `{rule_file.name}`",
208
+ "",
209
+ _strip_frontmatter(rule_file.read_text(encoding="utf-8")),
210
+ )
211
+ )
212
+ return "\n".join(parts).rstrip() + "\n"
213
+
214
+
215
+ def _validate_staged_plugin(plugin_dir: Path) -> list[str]:
216
+ errors: list[str] = []
217
+ manifest_path = plugin_dir / ".claude-plugin" / "plugin.json"
218
+ try:
219
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
220
+ except (OSError, json.JSONDecodeError) as exc:
221
+ return [f"invalid plugin manifest: {exc}"]
222
+
223
+ if not isinstance(manifest.get("repository"), str):
224
+ errors.append("plugin manifest repository must be a URL string")
225
+ for field in ("npm", "install", "marketplace", "claude-code"):
226
+ if field in manifest:
227
+ errors.append(f"plugin manifest contains ignored field: {field}")
228
+
229
+ hooks_path = plugin_dir / str(manifest.get("hooks", "")).removeprefix("./")
230
+ skills_path = plugin_dir / str(manifest.get("skills", "")).removeprefix("./")
231
+ if not hooks_path.is_file():
232
+ errors.append(f"plugin hook config missing: {hooks_path.relative_to(plugin_dir)}")
233
+ if not skills_path.is_dir():
234
+ errors.append(f"plugin skill path missing: {skills_path.relative_to(plugin_dir)}")
235
+
236
+ if hooks_path.is_file():
237
+ hooks_text = hooks_path.read_text(encoding="utf-8")
238
+ if "$HOME/.softspark/ai-toolkit/hooks/" in hooks_text:
239
+ errors.append("plugin hooks still reference the Claude Code global install")
240
+ if "${CLAUDE_PLUGIN_ROOT}/hooks/" not in hooks_text:
241
+ errors.append("plugin hooks do not reference CLAUDE_PLUGIN_ROOT")
242
+ try:
243
+ json.loads(hooks_text)
244
+ except json.JSONDecodeError as exc:
245
+ errors.append(f"invalid plugin hooks JSON: {exc}")
246
+
247
+ for agent in (plugin_dir / "agents").glob("*.md"):
248
+ if not agent.read_text(encoding="utf-8").startswith("---\n"):
249
+ errors.append(f"agent lacks frontmatter: {agent.name}")
250
+ return errors
251
+
252
+
253
+ def _run_claude_validator(plugin_dir: Path) -> bool:
254
+ claude = shutil.which("claude")
255
+ if not claude:
256
+ print("ERROR: claude CLI not found; cannot run official plugin validator", file=sys.stderr)
257
+ return False
258
+ result = subprocess.run(
259
+ [claude, "plugin", "validate", str(plugin_dir), "--strict"]
260
+ )
261
+ return result.returncode == 0
262
+
263
+
264
+ def verify_plugin() -> bool:
265
+ with tempfile.TemporaryDirectory(prefix="ai-toolkit-claude-app-") as tmp:
266
+ staged = Path(tmp) / "ai-toolkit"
267
+ stage_plugin(staged, include_custom_rules=False)
268
+ errors = _validate_staged_plugin(staged)
269
+ for error in errors:
270
+ print(f"ERROR: {error}", file=sys.stderr)
271
+ if errors:
272
+ return False
273
+ if not _run_claude_validator(staged):
274
+ return False
275
+ print("Claude app plugin validation passed")
276
+ return True
277
+
278
+
279
+ def _archive_file(zip_file: zipfile.ZipFile, path: Path, arcname: str) -> None:
280
+ data = path.read_bytes()
281
+ mode = path.stat().st_mode
282
+ permissions = 0o755 if mode & stat.S_IXUSR else 0o644
283
+ info = zipfile.ZipInfo(arcname, FIXED_ZIP_TIME)
284
+ info.compress_type = zipfile.ZIP_DEFLATED
285
+ info.external_attr = (stat.S_IFREG | permissions) << 16
286
+ zip_file.writestr(info, data)
287
+
288
+
289
+ def export_plugin(output: Path, include_custom_rules: bool, verify: bool) -> bool:
290
+ output = output.expanduser().resolve()
291
+ output.parent.mkdir(parents=True, exist_ok=True)
292
+ with tempfile.TemporaryDirectory(prefix="ai-toolkit-claude-app-") as tmp:
293
+ staged = Path(tmp) / "ai-toolkit"
294
+ registered_count = stage_plugin(staged, include_custom_rules=include_custom_rules)
295
+ errors = _validate_staged_plugin(staged)
296
+ if errors:
297
+ for error in errors:
298
+ print(f"ERROR: {error}", file=sys.stderr)
299
+ return False
300
+ if verify and not _run_claude_validator(staged):
301
+ return False
302
+ with zipfile.ZipFile(output, "w") as archive:
303
+ for path in sorted(staged.rglob("*")):
304
+ if path.is_file():
305
+ _archive_file(archive, path, path.relative_to(staged).as_posix())
306
+
307
+ instructions = output.with_name(f"{output.stem}-global-instructions.md")
308
+ _write_text(instructions, render_global_instructions())
309
+ print(f"Created: {output}")
310
+ print(f"Created: {instructions}")
311
+ if registered_count:
312
+ print(f"Included registered rules: {registered_count} (local bundle; review before sharing)")
313
+ print("Next: Claude > Customize > Plugins > + > Upload plugin")
314
+ print("Then paste the instructions file into Settings > Cowork > Global instructions")
315
+ return True
316
+
317
+
318
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
319
+ parser = argparse.ArgumentParser(description=__doc__)
320
+ subparsers = parser.add_subparsers(dest="action", required=True)
321
+ subparsers.add_parser("sync", help="refresh committed Claude app plugin assets")
322
+ subparsers.add_parser("verify", help="run structural and official plugin validation")
323
+ export = subparsers.add_parser("export", help="build an uploadable Claude app plugin ZIP")
324
+ export.add_argument("--output", default="ai-toolkit-claude-app.zip")
325
+ export.add_argument("--no-custom-rules", action="store_true")
326
+ export.add_argument("--verify", action="store_true")
327
+ return parser.parse_args(argv)
328
+
329
+
330
+ def main(argv: list[str] | None = None) -> int:
331
+ args = parse_args(argv)
332
+ if args.action == "sync":
333
+ sync_generated()
334
+ return 0
335
+ if args.action == "verify":
336
+ return 0 if verify_plugin() else 1
337
+ if args.action == "export":
338
+ return 0 if export_plugin(
339
+ Path(args.output),
340
+ include_custom_rules=not args.no_custom_rules,
341
+ verify=args.verify,
342
+ ) else 1
343
+ return 2
344
+
345
+
346
+ if __name__ == "__main__":
347
+ sys.exit(main())
package/scripts/doctor.py CHANGED
@@ -79,6 +79,10 @@ EXPECTED_HOOKS = [
79
79
 
80
80
  PLANNED_ASSETS = [
81
81
  toolkit_dir / "app" / ".claude-plugin" / "plugin.json",
82
+ toolkit_dir / "app" / "claude-app" / "hooks" / "hooks.json",
83
+ toolkit_dir / "app" / "claude-app" / "skills" / "ai-toolkit-rules" / "SKILL.md",
84
+ toolkit_dir / "app" / "claude-app" / "global-instructions.md",
85
+ toolkit_dir / "scripts" / "claude_app.py",
82
86
  toolkit_dir / "scripts" / "benchmark_ecosystem.py",
83
87
  toolkit_dir / "scripts" / "harvest_ecosystem.py",
84
88
  toolkit_dir / "kb" / "reference" / "claude-ecosystem-benchmark-snapshot.md",
@@ -218,14 +222,91 @@ def _check_symlinks(dr: DiagResult, fix_mode: bool, directory: Path,
218
222
  dr.fail(f"{label}: no symlinks found")
219
223
 
220
224
 
225
+ def _without_source_tags(value: object) -> object:
226
+ """Return a JSON-compatible hook value without private source markers."""
227
+ if isinstance(value, dict):
228
+ return {
229
+ key: _without_source_tags(item)
230
+ for key, item in value.items()
231
+ if key != "_source"
232
+ }
233
+ if isinstance(value, list):
234
+ return [_without_source_tags(item) for item in value]
235
+ return value
236
+
237
+
238
+ def _hook_signature(entry: dict) -> str:
239
+ """Build a stable behavior signature for tagged or legacy hook entries."""
240
+ return json.dumps(
241
+ _without_source_tags(entry),
242
+ ensure_ascii=False,
243
+ sort_keys=True,
244
+ separators=(",", ":"),
245
+ )
246
+
247
+
248
+ def _has_toolkit_source(entry: dict) -> bool:
249
+ if entry.get("_source") == "ai-toolkit":
250
+ return True
251
+ return any(
252
+ isinstance(handler, dict) and handler.get("_source") == "ai-toolkit"
253
+ for handler in entry.get("hooks", [])
254
+ )
255
+
256
+
257
+ def _installed_toolkit_hook_count(settings: dict) -> tuple[int, int]:
258
+ """Count canonical hooks even if Claude removed private `_source` tags."""
259
+ source_path = toolkit_dir / "app" / "hooks.json"
260
+ try:
261
+ source_hooks = json.loads(source_path.read_text(encoding="utf-8")).get(
262
+ "hooks", {}
263
+ )
264
+ except (OSError, json.JSONDecodeError):
265
+ return 0, 0
266
+
267
+ expected = sum(
268
+ len(entries) for entries in source_hooks.values() if isinstance(entries, list)
269
+ )
270
+ signatures = {
271
+ event: {
272
+ _hook_signature(entry)
273
+ for entry in entries
274
+ if isinstance(entry, dict)
275
+ }
276
+ for event, entries in source_hooks.items()
277
+ if isinstance(entries, list)
278
+ }
279
+ installed = 0
280
+ for event, entries in settings.get("hooks", {}).items():
281
+ if not isinstance(entries, list):
282
+ continue
283
+ for entry in entries:
284
+ if not isinstance(entry, dict):
285
+ continue
286
+ if _has_toolkit_source(entry) or _hook_signature(entry) in signatures.get(
287
+ event, set()
288
+ ):
289
+ installed += 1
290
+ return installed, expected
291
+
292
+
221
293
  def _check_settings_json(dr: DiagResult) -> None:
222
294
  """Check settings.json hooks and legacy hooks.json."""
223
295
  settings_json = CLAUDE_DIR / "settings.json"
224
296
  if settings_json.is_file():
225
- content = settings_json.read_text(encoding="utf-8")
226
- if '"_source"' in content and '"ai-toolkit"' in content:
227
- hook_count = content.count('"ai-toolkit"')
228
- dr.ok(f"settings.json: {hook_count} toolkit hook entries")
297
+ try:
298
+ settings = json.loads(settings_json.read_text(encoding="utf-8"))
299
+ except (OSError, json.JSONDecodeError):
300
+ dr.fail("settings.json is not valid JSON")
301
+ settings = {}
302
+ hook_count, expected_count = _installed_toolkit_hook_count(settings)
303
+ if expected_count and hook_count == expected_count:
304
+ dr.ok(f"settings.json: {hook_count}/{expected_count} toolkit hook entries")
305
+ elif hook_count:
306
+ dr.warn(
307
+ f"settings.json has {hook_count}/{expected_count} toolkit hook entries "
308
+ "(run: ai-toolkit update --only hooks)"
309
+ )
229
310
  else:
230
311
  dr.warn("settings.json exists but has no toolkit hooks")
231
312
  else:
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "description": "Authoritative registry of tools ai-toolkit integrates with. Consumed by scripts/ecosystem_doctor.py to detect upstream doc/version drift.",
4
- "last_updated": "2026-06-30",
4
+ "last_updated": "2026-07-10",
5
5
  "tools": [
6
6
  {
7
7
  "id": "claude-code",
@@ -70,6 +70,37 @@
70
70
  "command": "claude --version"
71
71
  }
72
72
  },
73
+ {
74
+ "id": "claude-app",
75
+ "display_name": "Claude Chat / Cowork",
76
+ "kind": "app",
77
+ "urls": {
78
+ "docs": "https://support.claude.com/en/articles/13345190-get-started-with-claude-cowork",
79
+ "release_notes": "https://support.claude.com/en/articles/13837440-use-plugins-in-claude",
80
+ "changelog": null
81
+ },
82
+ "config_paths": [
83
+ "UI: Settings > Cowork > Global instructions",
84
+ "UI: Cowork folder instructions",
85
+ "UI: Customize > Skills",
86
+ "UI: Customize > Plugins",
87
+ ".claude-plugin/plugin.json",
88
+ "claude-app/hooks/hooks.json",
89
+ "claude-app/skills/*/SKILL.md"
90
+ ],
91
+ "our_generators": [
92
+ "scripts/claude_app.py"
93
+ ],
94
+ "status_note": "Claude Chat/Desktop/Cowork does not scan ~/.claude/rules, CLAUDE.md, or Claude Code's user settings. ai-toolkit exports an uploadable plugin ZIP plus Cowork global instructions. Skills work in Chat and Cowork; plugin hooks and sub-agents run only in Cowork.",
95
+ "capability_markers": [
96
+ "Global instructions",
97
+ "Folder instructions",
98
+ "plugins",
99
+ "skills",
100
+ "sub-agents"
101
+ ],
102
+ "version_probe": null
103
+ },
73
104
  {
74
105
  "id": "cursor",
75
106
  "display_name": "Cursor",
@@ -134,7 +165,6 @@
134
165
  "config_paths": [
135
166
  ".devin/rules/*.md",
136
167
  ".devin/workflows/*.md",
137
- ".devin/skills/*/SKILL.md",
138
168
  ".devin/hooks.v1.json",
139
169
  ".devin/config.json",
140
170
  ".devin/config.local.json",
@@ -153,10 +183,9 @@
153
183
  "scripts/generate_windsurf.py",
154
184
  "scripts/generate_windsurf_rules.py",
155
185
  "scripts/generate_windsurf_skills.py",
156
- "scripts/generate_windsurf_hooks.py",
157
186
  "scripts/generate_devin_hooks.py"
158
187
  ],
159
- "status_note": "Windsurf rebranded to Devin Desktop on 2026-06-02. .devin/ is the primary read+write workspace tree; .windsurfrules, .windsurf/* and ~/.codeium/windsurf/* are legacy read-only fallbacks. Our generators dual-emit .devin/* + .windsurf/*. HOOKS MIGRATION (done): Cascade agent is available only through 2026-07-01 (Devin Local default since 2026-06-02), and the Cascade-scoped .windsurf/hooks.json (generate_windsurf_hooks.py, agent_action_name/tool_info format) dies with it — Devin CLI/Local do NOT read it as a fallback. generate_devin_hooks.py now emits .devin/hooks.v1.json in the Claude-compatible hook format Devin CLI uses (docs.devin.ai/cli/extensibility/hooks): Claude-style events (PreToolUse/PostToolUse/UserPromptSubmit/Stop/SessionStart) with matchers on Devin tool names (read/edit/exec/mcp__*), flat {decision,reason} block contract + exit 2. Globally-installed users ALSO inherit ~/.claude/settings.json hooks automatically (Devin read_config_from.claude defaults on). Both hook generators run at profile=full; drop generate_windsurf_hooks.py in the first release after 2026-07-01. Devin CLI ('Devin for Terminal', launched 2026-04-29) shares the Devin Local harness, reads AGENTS.md and the same SKILL.md standard; not yet tracked as a separate registry entry.",
188
+ "status_note": "Windsurf rebranded to Devin Desktop on 2026-06-02. .devin/ is the primary rules/workflows/hooks tree; legacy .windsurf rules remain a read fallback. Cascade and its .windsurf/hooks.json format ended on 2026-07-01, so ai-toolkit now emits only .devin/hooks.v1.json. Current Devin skill docs list eight scanned paths (.agents/skills recommended, plus .devin/skills, .github/skills, .claude/skills, .cursor/skills, .codex/skills, .cognition/skills, .windsurf/skills); ai-toolkit emits a single canonical pointer under .windsurf/skills and no longer also writes .devin/skills. Devin CLI also imports ~/.claude/settings.json hooks when read_config_from.claude is enabled.",
160
189
  "capability_markers": [
161
190
  "Cascade",
162
191
  "windsurfrules",
@@ -469,7 +498,6 @@
469
498
  ],
470
499
  "status_note": "Antigravity CLI (GA 2026-05-19) reads workspace skills from .agents/skills/ (plural); the IDE still uses .agent/skills/ (singular) — our generator dual-emits the skill pointer to both. CLI hooks live in .agents/hooks.json with Claude-style event names (PreToolUse, not BeforeTool) and a JSON stdin/stdout decision contract. CLI MCP config is .agents/mcp_config.json with a required serverUrl field (url/httpUrl rejected silently); the GLOBAL MCP config path is reported inconsistently across sources (~/.gemini/antigravity-cli/ vs ~/.gemini/config/), so only the workspace path is registered until verified via agy inspect. Official docs are fully JS-rendered — the doctor is blind to them; verify changes manually each sync.",
471
500
  "capability_markers": [
472
- "Antigravity",
473
501
  "agent manager",
474
502
  "artifacts",
475
503
  "MCP",
@@ -3,10 +3,9 @@
3
3
 
4
4
  Devin CLI uses a hook format **compatible with Claude Code hooks**
5
5
  (docs.devin.ai/cli/extensibility/hooks/overview). This generator is the
6
- replacement for the deprecated Cascade ``.windsurf/hooks.json`` surface,
7
- which stops working when Cascade sunsets on 2026-07-01. Devin Local / Devin
8
- CLI do NOT read ``.windsurf/hooks.json`` as a fallback, so the hooks must be
9
- regenerated onto this new file.
6
+ replacement for the removed Cascade ``.windsurf/hooks.json`` surface, which
7
+ stopped working on 2026-07-01. Devin Local and the standalone Devin CLI do not
8
+ read that legacy file as a fallback, so hooks are emitted here.
10
9
 
11
10
  Output file: ``<target>/.devin/hooks.v1.json``. Per the Devin docs the
12
11
  standalone ``hooks.v1.json`` file's entire contents ARE the hooks object —
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env python3
2
- """Generate a Devin Desktop (formerly Windsurf) skill pointer.
2
+ """Generate a Devin/Windsurf skill pointer.
3
3
 
4
- Dual-emits under ``.devin/skills/`` (primary since the 2026-06-02 rebrand)
5
- and ``.windsurf/skills/`` (legacy fallback). Pass an explicit ``skill_root``
6
- to emit a single location instead (used for the ``~/.codeium/windsurf/``
7
- global install path).
4
+ Current Devin docs list ``.windsurf/skills/`` as a supported cross-tool skill
5
+ path and do not list ``.devin/skills/``. Pass an explicit ``skill_root`` for a
6
+ single alternate location (used by the legacy HOME-scoped Windsurf install).
8
7
  """
9
8
  from __future__ import annotations
10
9
 
@@ -14,7 +13,7 @@ from pathlib import Path
14
13
  sys.path.insert(0, str(Path(__file__).resolve().parent))
15
14
  from skill_pointer import POINTER_SKILL_NAME, write_pointer_skill
16
15
 
17
- DEFAULT_SKILL_ROOTS: tuple[str, ...] = (".devin/skills", ".windsurf/skills")
16
+ DEFAULT_SKILL_ROOTS: tuple[str, ...] = (".windsurf/skills",)
18
17
 
19
18
 
20
19
  def generate(target_dir: Path, *, emit_skill_pointer: bool = True,