@softspark/ai-toolkit 4.6.0 → 4.8.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.
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env python3
2
+ """Generate ``.devin/hooks.v1.json`` for the Devin CLI (formerly Windsurf).
3
+
4
+ Devin CLI uses a hook format **compatible with Claude Code hooks**
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.
10
+
11
+ Output file: ``<target>/.devin/hooks.v1.json``. Per the Devin docs the
12
+ standalone ``hooks.v1.json`` file's entire contents ARE the hooks object —
13
+ there is **no** top-level ``"hooks"`` wrapper key (unlike
14
+ ``.claude/settings.json`` or ``.devin/config.json``).
15
+
16
+ Events use Claude-style PascalCase names. Matchers are regexes against the
17
+ Devin **tool name** (``read``, ``edit``, ``exec``, ``grep``, ``glob``,
18
+ ``mcp__<server>__<tool>``) — NOT Claude's ``Bash``/``Edit`` names — so the
19
+ shared guard scripts reliably fire under Devin.
20
+
21
+ Blocking contract: the shared guard scripts emit ``{"decision":"block",
22
+ "reason":...}`` on stdout (plain mode) AND exit 2 — Devin honors both (docs:
23
+ exit 2 = deny; JSON ``{"decision":"block"}`` = deny). Hooks therefore run
24
+ WITHOUT ``AI_TOOLKIT_HOOK_FORMAT=json`` because Devin expects the flat
25
+ ``{"decision","reason"}`` shape, not Claude's ``hookSpecificOutput`` envelope.
26
+
27
+ Existing user hook entries are preserved; only entries tagged
28
+ ``_source: ai-toolkit`` are replaced on regeneration.
29
+
30
+ Usage:
31
+ python3 scripts/generate_devin_hooks.py [target-dir]
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ HOOKS_PREFIX = '"$HOME/.softspark/ai-toolkit/hooks/'
40
+ SOURCE_TAG = "ai-toolkit"
41
+
42
+ # event -> list of (matcher_regex, [script names]).
43
+ # Matchers target Devin tool names: read, edit, exec, mcp__<server>__<tool>.
44
+ # An empty matcher fires for every tool name (Devin: omitted/empty = match all).
45
+ DEVIN_HOOKS: dict[str, list[tuple[str, list[str]]]] = {
46
+ "PreToolUse": [
47
+ ("^(read|edit)$", ["guard-path.sh"]),
48
+ ("^edit$", ["guard-config.sh"]),
49
+ ("^exec$", ["guard-destructive.sh", "commit-quality.sh", "revert-guard.sh"]),
50
+ ("^mcp__", ["guard-config.sh"]),
51
+ ],
52
+ "PostToolUse": [
53
+ ("^edit$", ["post-tool-use.sh", "governance-capture.sh", "test-cohesion.sh"]),
54
+ ("^exec$", ["governance-capture.sh"]),
55
+ ("^mcp__.*__(smart_query|hybrid_search_kb|crag_search|multi_hop_search|verify_answer)$",
56
+ ["search-tracker.sh"]),
57
+ ],
58
+ "UserPromptSubmit": [
59
+ ("", ["user-prompt-submit.sh", "track-usage.sh"]),
60
+ ],
61
+ "Stop": [
62
+ ("", ["quality-check.sh", "save-session.sh", "stop-search-check.sh"]),
63
+ ],
64
+ # Cascade's post_setup_worktree has no Devin equivalent; session-context
65
+ # moves to SessionStart (Devin fires SessionStart when a session begins).
66
+ "SessionStart": [
67
+ ("", ["session-context.sh"]),
68
+ ],
69
+ }
70
+
71
+
72
+ def build_hook_entry(matcher: str, scripts: list[str]) -> dict:
73
+ """Build one Devin matcher-group: ``{matcher, hooks:[{type,command}]}``."""
74
+ return {
75
+ "_source": SOURCE_TAG,
76
+ "matcher": matcher,
77
+ "hooks": [
78
+ {"type": "command", "command": f'{HOOKS_PREFIX}{s}"'}
79
+ for s in scripts
80
+ ],
81
+ }
82
+
83
+
84
+ def build_toolkit_hooks() -> dict[str, list[dict]]:
85
+ return {
86
+ event: [build_hook_entry(matcher, scripts) for matcher, scripts in groups]
87
+ for event, groups in DEVIN_HOOKS.items()
88
+ }
89
+
90
+
91
+ def _is_toolkit_entry(entry: dict) -> bool:
92
+ return isinstance(entry, dict) and entry.get("_source") == SOURCE_TAG
93
+
94
+
95
+ def strip_toolkit_hooks(hooks: dict) -> dict:
96
+ """Drop ai-toolkit matcher-groups; keep user-authored entries."""
97
+ kept: dict = {}
98
+ for event, entries in hooks.items():
99
+ if not isinstance(entries, list):
100
+ kept[event] = entries
101
+ continue
102
+ survivors = [e for e in entries if not _is_toolkit_entry(e)]
103
+ if survivors:
104
+ kept[event] = survivors
105
+ return kept
106
+
107
+
108
+ def merge_hooks(existing: dict, toolkit: dict) -> dict:
109
+ merged = strip_toolkit_hooks(existing)
110
+ for event, entries in toolkit.items():
111
+ merged.setdefault(event, []).extend(entries)
112
+ return merged
113
+
114
+
115
+ def generate(target_dir: Path) -> Path:
116
+ devin_dir = target_dir / ".devin"
117
+ devin_dir.mkdir(parents=True, exist_ok=True)
118
+ path = devin_dir / "hooks.v1.json"
119
+
120
+ # The standalone hooks.v1.json file IS the hooks object (no wrapper key).
121
+ existing: dict = {}
122
+ if path.is_file():
123
+ try:
124
+ with open(path, encoding="utf-8") as f:
125
+ existing = json.load(f)
126
+ if not isinstance(existing, dict):
127
+ existing = {}
128
+ except (json.JSONDecodeError, OSError):
129
+ existing = {}
130
+
131
+ merged = merge_hooks(existing, build_toolkit_hooks())
132
+
133
+ with open(path, "w", encoding="utf-8") as f:
134
+ json.dump(merged, f, indent=4, ensure_ascii=False, sort_keys=True)
135
+ f.write("\n")
136
+ return path
137
+
138
+
139
+ def main() -> None:
140
+ target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
141
+ path = generate(target)
142
+ total = sum(len(scripts) for groups in DEVIN_HOOKS.values()
143
+ for _, scripts in groups)
144
+ rel = path.relative_to(target) if path.is_relative_to(target) else path
145
+ print(f"Generated: {rel} ({total} hooks across {len(DEVIN_HOOKS)} events)")
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env python3
2
2
  """Generate .windsurf/hooks.json for Windsurf Cascade.
3
3
 
4
+ DEPRECATED SURFACE: the Cascade agent is available only through 2026-07-01
5
+ (Devin Local is the default agent since the 2026-06-02 Devin Desktop rebrand),
6
+ and this hooks.json schema is Cascade-scoped. Migrate to the Devin CLI
7
+ lifecycle-hooks surface (docs.devin.ai/cli/extensibility/hooks/*) before that
8
+ date; tracked in the ecosystem registry status_note for windsurf.
9
+
4
10
  Writes `<target>/.windsurf/hooks.json`. Existing user hook entries are
5
11
  preserved; only entries tagged `_source: ai-toolkit` are replaced on
6
12
  regeneration. Windsurf merges system / user / workspace hooks at runtime, so
@@ -1,17 +1,25 @@
1
1
  #!/usr/bin/env python3
2
- """Generate ``.windsurf/rules/*.md`` (and workflows) for Windsurf IDE.
2
+ """Generate ``.devin/rules/*.md`` and ``.windsurf/rules/*.md`` (and workflows)
3
+ for Devin Desktop (formerly Windsurf).
3
4
 
4
- Windsurf reads directory-based rules from ``.windsurf/rules/*.md`` (since
5
- mid-2025). Each rule file supports YAML frontmatter with these fields
6
- (from docs.windsurf.com/windsurf/cascade/memories):
5
+ Windsurf rebranded to Devin Desktop on 2026-06-02. ``.devin/`` is now the
6
+ primary read+write workspace tree; ``.windsurf/`` is a legacy read-only
7
+ fallback that current builds still honor (docs.devin.ai/desktop/devin-desktop-faq:
8
+ "The application already supports .devin/ as the primary workspace directory
9
+ and falls back to .windsurf/ for backward compatibility"). We dual-emit the
10
+ same content to both trees so old Windsurf builds keep working during the
11
+ transition.
12
+
13
+ Each rule file supports YAML frontmatter with these fields
14
+ (from docs.devin.ai/desktop — memories docs):
7
15
 
8
16
  * ``trigger`` — activation mode: ``always_on`` | ``glob`` | ``model_decision``
9
- | ``manual``. When omitted, Windsurf defaults to manual-only.
17
+ | ``manual``. When omitted, the IDE defaults to manual-only.
10
18
  * ``globs`` — comma-separated glob patterns (only when ``trigger: glob``).
11
19
  * ``description`` — shown to the model when ``trigger: model_decision``.
12
20
 
13
- Windsurf also reads workflow markdown files from ``.windsurf/workflows/*.md``
14
- which users invoke via ``/<name>`` slash commands (Cascade). This generator
21
+ Workflow markdown files (invoked via ``/<name>`` slash commands) are emitted
22
+ to ``.devin/workflows/*.md`` and ``.windsurf/workflows/*.md``. This generator
15
23
  emits the same workflow catalogue used by Antigravity and Cline.
16
24
 
17
25
  The legacy ``.windsurfrules`` single-file format is still produced by
@@ -142,20 +150,26 @@ def _build_rules(language_modules: list[str] | None,
142
150
 
143
151
 
144
152
  # ---------------------------------------------------------------------------
145
- # Workflows — .windsurf/workflows/<name>.md invocable via /<name>
153
+ # Workflows — <tree>/workflows/<name>.md invocable via /<name>
146
154
  # ---------------------------------------------------------------------------
147
155
 
156
+ # .devin/ is primary since the 2026-06-02 Devin Desktop rebrand; .windsurf/
157
+ # is the legacy fallback still read by pre-rebrand builds.
158
+ CONFIG_TREES: tuple[str, ...] = (".devin", ".windsurf")
159
+
160
+
148
161
  def _write_workflows(target_dir: Path, *, cleanup: bool = True) -> None:
149
- """Write ``.windsurf/workflows/*.md`` files for Cascade slash commands."""
150
- workflows_dir = target_dir / ".windsurf" / "workflows"
151
- workflows_dir.mkdir(parents=True, exist_ok=True)
162
+ """Write ``workflows/*.md`` slash-command files to both config trees."""
163
+ for tree in CONFIG_TREES:
164
+ workflows_dir = target_dir / tree / "workflows"
165
+ workflows_dir.mkdir(parents=True, exist_ok=True)
152
166
 
153
- if cleanup:
154
- cleanup_stale(workflows_dir, set(STANDARD_WORKFLOWS.keys()))
167
+ if cleanup:
168
+ cleanup_stale(workflows_dir, set(STANDARD_WORKFLOWS.keys()))
155
169
 
156
- for filename, content_fn in STANDARD_WORKFLOWS.items():
157
- (workflows_dir / filename).write_text(content_fn(), encoding="utf-8")
158
- print(f" Generated: .windsurf/workflows/{filename}")
170
+ for filename, content_fn in STANDARD_WORKFLOWS.items():
171
+ (workflows_dir / filename).write_text(content_fn(), encoding="utf-8")
172
+ print(f" Generated: {tree}/workflows/{filename}")
159
173
 
160
174
 
161
175
  # ---------------------------------------------------------------------------
@@ -170,15 +184,16 @@ def generate(target_dir: Path, *,
170
184
  managed_scopes: tuple[str, ...] = (
171
185
  STANDARD_SCOPE, LANG_SCOPE, CUSTOM_SCOPE,
172
186
  )) -> None:
173
- """Write ``.windsurf/rules/*.md`` and ``.windsurf/workflows/*.md``."""
187
+ """Write ``rules/*.md`` and ``workflows/*.md`` to both config trees."""
174
188
  rules = _build_rules(language_modules, rules_dir)
175
- write_rules(
176
- target_dir,
177
- rules,
178
- ".windsurf/rules",
179
- cleanup=cleanup,
180
- managed_scopes=managed_scopes,
181
- )
189
+ for tree in CONFIG_TREES:
190
+ write_rules(
191
+ target_dir,
192
+ rules,
193
+ f"{tree}/rules",
194
+ cleanup=cleanup,
195
+ managed_scopes=managed_scopes,
196
+ )
182
197
 
183
198
  if emit_workflows:
184
199
  _write_workflows(target_dir, cleanup=cleanup)
@@ -1,5 +1,11 @@
1
1
  #!/usr/bin/env python3
2
- """Generate a Windsurf skill pointer under ``.windsurf/skills/``."""
2
+ """Generate a Devin Desktop (formerly Windsurf) skill pointer.
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).
8
+ """
3
9
  from __future__ import annotations
4
10
 
5
11
  import sys
@@ -8,13 +14,17 @@ from pathlib import Path
8
14
  sys.path.insert(0, str(Path(__file__).resolve().parent))
9
15
  from skill_pointer import POINTER_SKILL_NAME, write_pointer_skill
10
16
 
17
+ DEFAULT_SKILL_ROOTS: tuple[str, ...] = (".devin/skills", ".windsurf/skills")
18
+
11
19
 
12
20
  def generate(target_dir: Path, *, emit_skill_pointer: bool = True,
13
- skill_root: str = ".windsurf/skills") -> None:
21
+ skill_root: str | None = None) -> None:
14
22
  if not emit_skill_pointer:
15
23
  return
16
- write_pointer_skill(target_dir, skill_root, "Windsurf")
17
- print(f" Generated: {skill_root}/{POINTER_SKILL_NAME}/SKILL.md")
24
+ roots = (skill_root,) if skill_root else DEFAULT_SKILL_ROOTS
25
+ for root in roots:
26
+ write_pointer_skill(target_dir, root, "Windsurf")
27
+ print(f" Generated: {root}/{POINTER_SKILL_NAME}/SKILL.md")
18
28
 
19
29
 
20
30
  def main() -> None:
@@ -381,6 +381,9 @@ _EDITOR_MARKERS: dict[str, str] = {
381
381
  ".cursor/rules": "cursor",
382
382
  ".windsurfrules": "windsurf",
383
383
  ".windsurf/rules": "windsurf",
384
+ # .devin/ is the primary Devin Desktop tree since the 2026-06-02 rebrand;
385
+ # .windsurf/ markers stay to detect legacy installs.
386
+ ".devin/rules": "windsurf",
384
387
  ".clinerules": "cline",
385
388
  ".roomodes": "roo",
386
389
  ".roo/rules": "roo",
@@ -411,11 +414,25 @@ def _detect_editors(cwd: Path) -> list[str]:
411
414
  found: set[str] = set()
412
415
  for marker, editor in _EDITOR_MARKERS.items():
413
416
  p = cwd / marker
414
- if p.exists():
415
- found.add(editor)
417
+ if not p.exists():
418
+ continue
419
+ if marker == ".agents/skills" and _is_pointer_only_skills_dir(p):
420
+ # The Antigravity CLI pointer skill also lives in .agents/skills/;
421
+ # only real (materialized) skills indicate a Codex install.
422
+ continue
423
+ found.add(editor)
416
424
  return sorted(found)
417
425
 
418
426
 
427
+ def _is_pointer_only_skills_dir(skills_dir: Path) -> bool:
428
+ """True when a skills dir holds only the ai-toolkit pointer skill."""
429
+ try:
430
+ entries = [e.name for e in skills_dir.iterdir() if not e.name.startswith(".")]
431
+ except OSError:
432
+ return False
433
+ return entries == ["ai-toolkit-skill-catalogue"]
434
+
435
+
419
436
  def _resolve_editors(editors_arg: str, cwd: Path) -> list[str]:
420
437
  """Resolve --editors argument to a list of editor names.
421
438
 
@@ -709,7 +726,7 @@ def _install_local_dry_run(reset: bool, editors: list[str] | None = None,
709
726
  _EDITOR_DRY_RUN = {
710
727
  "copilot": " Would inject: .github/copilot-instructions.md",
711
728
  "cursor": " Would generate: .cursorrules + .cursor/rules/*.mdc",
712
- "windsurf": " Would generate: .windsurfrules + .windsurf/rules/*.md",
729
+ "windsurf": " Would generate: .windsurfrules + .devin/rules/*.md + .windsurf/rules/*.md",
713
730
  "cline": " Would generate: .clinerules/*.md",
714
731
  "roo": " Would generate: .roomodes + .roo/rules/*.md",
715
732
  "aider": " Would generate: .aider.conf.yml + CONVENTIONS.md",
@@ -731,7 +748,7 @@ def _install_local_dry_run(reset: bool, editors: list[str] | None = None,
731
748
  if "cursor" in eds:
732
749
  print(" Would generate: .cursor/hooks.json + .cursor/agents/ + .cursor/skills/ (profile=full)")
733
750
  if "windsurf" in eds:
734
- print(" Would generate: .windsurf/hooks.json + .windsurf/skills/ (profile=full)")
751
+ print(" Would generate: .devin/hooks.v1.json + .windsurf/hooks.json (Cascade, deprecated) + .devin/skills/ + .windsurf/skills/ (profile=full)")
735
752
  if "cline" in eds:
736
753
  print(" Would generate: .cline/skills/ (profile=full)")
737
754
  if "augment" in eds:
@@ -963,7 +980,10 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
963
980
  gen_windsurf_rules(cwd, language_modules=language_modules,
964
981
  rules_dir=rules_dir)
965
982
  if add_native_surfaces:
983
+ # .windsurf/hooks.json is Cascade-scoped and dies 2026-07-01;
984
+ # .devin/hooks.v1.json is the Devin CLI replacement (Claude format).
966
985
  _try_generator("generate_windsurf_hooks", cwd)
986
+ _try_generator("generate_devin_hooks", cwd)
967
987
  # Windsurf pointer stays unconditional (its .claude scan is gated).
968
988
  _try_generator("generate_windsurf_skills", cwd)
969
989
 
@@ -846,11 +846,15 @@ def _validate_version_sync(tk_dir: Path, vr: ValidationResult) -> None:
846
846
  # generators come and go.
847
847
  _NATIVE_HOOK_EDITORS = {"claude", "opencode"}
848
848
 
849
+ # generate_<stem>_hooks.py stems that belong to an existing README platform key.
850
+ _HOOK_STEM_ALIAS = {"devin": "windsurf"}
851
+
849
852
  # README platform label (lowercased) -> canonical editor key.
850
853
  _README_PLATFORM_KEY = {
851
854
  "claude code": "claude",
852
855
  "cursor": "cursor",
853
856
  "windsurf": "windsurf",
857
+ "windsurf (devin desktop)": "windsurf",
854
858
  "gemini cli": "gemini",
855
859
  "github copilot": "copilot",
856
860
  "cline": "cline",
@@ -876,10 +880,12 @@ def _validate_editor_hooks_honesty(tk_dir: Path, vr: ValidationResult) -> None:
876
880
  return # installed copy without source — nothing to cross-check
877
881
 
878
882
  # Actual hook-enabled editors: native set + generate_<editor>_hooks.py stems.
883
+ # Some stems map back to a README platform key (e.g. the Devin CLI hooks
884
+ # generator is part of the windsurf/Devin-Desktop integration).
879
885
  actual = set(_NATIVE_HOOK_EDITORS)
880
886
  for gen in scripts_dir.glob("generate_*_hooks.py"):
881
887
  stem = gen.name[len("generate_"):-len("_hooks.py")]
882
- actual.add(stem)
888
+ actual.add(_HOOK_STEM_ALIAS.get(stem, stem))
883
889
 
884
890
  content = readme.read_text(encoding="utf-8")
885
891
  if "| Hooks |" not in content: