specify-cli 1.2.2__py3-none-any.whl → 1.2.3__py3-none-any.whl

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 (32) hide show
  1. specify_cli/_install_ignore.py +87 -0
  2. specify_cli/command_check.py +2 -2
  3. specify_cli/command_init.py +18 -0
  4. specify_cli/core_pack/extensions/github/README.md +2 -2
  5. specify_cli/core_pack/extensions/github/extension.yml +1 -1
  6. specify_cli/core_pack/scripts/powershell/check-prerequisites.ps1 +8 -8
  7. specify_cli/core_pack/scripts/powershell/common.ps1 +2 -2
  8. specify_cli/core_pack/scripts/powershell/setup-tasks.ps1 +6 -6
  9. specify_cli/core_pack/scripts/python/check_prerequisites.py +2 -1
  10. specify_cli/core_pack/scripts/python/resolve_template.py +2 -1
  11. specify_cli/core_pack/scripts/python/setup_plan.py +2 -1
  12. specify_cli/core_pack/scripts/python/setup_tasks.py +2 -1
  13. specify_cli/events/__init__.py +90 -164
  14. specify_cli/extensions/__init__.py +2 -62
  15. specify_cli/extensions/catalog/command_remove.py +8 -1
  16. specify_cli/integrations/__init__.py +2 -0
  17. specify_cli/integrations/base.py +25 -10
  18. specify_cli/integrations/command_list.py +1 -1
  19. specify_cli/integrations/command_upgrade.py +1 -0
  20. specify_cli/integrations/copilot/__init__.py +2 -1
  21. specify_cli/integrations/kimi/__init__.py +29 -0
  22. specify_cli/integrations/none/__init__.py +29 -0
  23. specify_cli/integrations/pi/__init__.py +34 -0
  24. specify_cli/integrations/vibe/__init__.py +394 -1
  25. specify_cli/presets/_manager.py +5 -1
  26. specify_cli/workflows/_mcp.py +377 -0
  27. specify_cli/workflows/engine.py +9 -1
  28. {specify_cli-1.2.2.dist-info → specify_cli-1.2.3.dist-info}/METADATA +69 -48
  29. {specify_cli-1.2.2.dist-info → specify_cli-1.2.3.dist-info}/RECORD +32 -29
  30. {specify_cli-1.2.2.dist-info → specify_cli-1.2.3.dist-info}/WHEEL +0 -0
  31. {specify_cli-1.2.2.dist-info → specify_cli-1.2.3.dist-info}/entry_points.txt +0 -0
  32. {specify_cli-1.2.2.dist-info → specify_cli-1.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,87 @@
1
+ """Shared source-directory installation filtering for extensions and presets."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Callable, List, Optional, Set
6
+
7
+ import pathspec
8
+
9
+
10
+ def load_install_ignore(
11
+ source_dir: Path,
12
+ ignore_filename: str,
13
+ validation_error: type[Exception],
14
+ ) -> Optional[Callable[[str, List[str]], Set[str]]]:
15
+ """Load a UTF-8 ignore file as a copytree callback, or return None if absent.
16
+
17
+ Match the extension installer's Git-style patterns, including whitespace
18
+ trimming and backslash normalization. Only the named root ignore file is
19
+ read; there are no default exclusions except the ignore filename itself.
20
+ Invalid encoding or patterns raise the caller's domain validation error
21
+ before copying.
22
+ Excluded directories are pruned by copytree, so child negations cannot
23
+ restore files inside them unless the parent directory is also re-included.
24
+ """
25
+ ignore_file = source_dir / ignore_filename
26
+ if not ignore_file.exists():
27
+ return None
28
+
29
+ # Accept UTF-8 with or without the BOM emitted by Windows PowerShell 5.1.
30
+ # Strip it after decoding so error offsets remain relative to the file.
31
+ try:
32
+ raw = ignore_file.read_text(encoding="utf-8").removeprefix("\ufeff")
33
+ except UnicodeDecodeError as e:
34
+ raise validation_error(
35
+ f"{ignore_filename} is not valid UTF-8: {ignore_file} "
36
+ f"({e.reason} at byte {e.start})"
37
+ )
38
+ lines: List[str] = raw.splitlines()
39
+
40
+ # Normalise backslashes in patterns so Windows-authored files work
41
+ normalised: List[str] = []
42
+ for line in lines:
43
+ stripped = line.strip()
44
+ if stripped and not stripped.startswith("#"):
45
+ normalised.append(stripped.replace("\\", "/"))
46
+ else:
47
+ # Preserve blanks/comments so pathspec line numbers stay stable
48
+ normalised.append(line)
49
+
50
+ # Always ignore the ignore file itself, even after a negation pattern.
51
+ normalised.append(ignore_filename)
52
+
53
+ try:
54
+ spec = pathspec.GitIgnoreSpec.from_lines(normalised)
55
+ except (ValueError, re.error) as e:
56
+ # Locate the first malformed source rule only on failure. Valid files
57
+ # compile once; the appended self-exclusion is not a source line.
58
+ location = ""
59
+ for line_number, pattern in enumerate(normalised[:len(lines)], start=1):
60
+ try:
61
+ pathspec.GitIgnoreSpec.from_lines([pattern])
62
+ except (ValueError, re.error):
63
+ location = f" at line {line_number}: {pattern!r}"
64
+ break
65
+ raise validation_error(
66
+ f"Invalid patterns in {ignore_filename}: {ignore_file}{location} ({e})"
67
+ ) from e
68
+
69
+ def _ignore(directory: str, entries: List[str]) -> Set[str]:
70
+ ignored: Set[str] = set()
71
+ rel_dir = Path(directory).relative_to(source_dir)
72
+ for entry in entries:
73
+ rel_path = str(rel_dir / entry) if str(rel_dir) != "." else entry
74
+ # Normalise to forward slashes for consistent matching
75
+ rel_path_fwd = rel_path.replace("\\", "/")
76
+
77
+ entry_full = Path(directory) / entry
78
+ if entry_full.is_dir():
79
+ # Append '/' so directory-only patterns (e.g. tests/) match
80
+ if spec.match_file(rel_path_fwd + "/"):
81
+ ignored.add(entry)
82
+ else:
83
+ if spec.match_file(rel_path_fwd):
84
+ ignored.add(entry)
85
+ return ignored
86
+
87
+ return _ignore
@@ -30,7 +30,7 @@ def _check_external_tools(
30
30
  args = integration.build_exec_args("test", project_root=project_root)
31
31
  tools = [args[0] if args else integration._resolve_executable()]
32
32
  if not tools:
33
- tracker.skip(key, "IDE-based, no CLI check")
33
+ tracker.skip(key, "no CLI required")
34
34
  return False
35
35
  tools = list(dict.fromkeys(tools))
36
36
  missing = [tool for tool in tools if shutil.which(tool) is None]
@@ -90,7 +90,7 @@ def check() -> None:
90
90
  elif requires_cli:
91
91
  agent_results[agent_key] = check_tool(agent_key, tracker=tracker)
92
92
  else:
93
- tracker.skip(agent_key, "IDE-based, no CLI check")
93
+ tracker.skip(agent_key, "no CLI required")
94
94
  agent_results[agent_key] = False
95
95
 
96
96
  tracker.add("code", "Visual Studio Code")
@@ -1057,6 +1057,24 @@ def register(app: typer.Typer) -> None:
1057
1057
  steps_lines.append("1. You're already in the project directory!")
1058
1058
  step_num = 2
1059
1059
 
1060
+ if not resolved_integration.list_command_templates():
1061
+ steps_lines.append(
1062
+ f"{step_num}. Choose an integration: [cyan]specify integration list[/cyan]"
1063
+ )
1064
+ steps_lines.append(
1065
+ f"{step_num + 1}. Switch this project to it: [cyan]specify integration switch <key>[/cyan]"
1066
+ )
1067
+ console.print()
1068
+ console.print(
1069
+ Panel(
1070
+ "\n".join(steps_lines),
1071
+ title="Next Steps",
1072
+ border_style="cyan",
1073
+ padding=(1, 2),
1074
+ )
1075
+ )
1076
+ return
1077
+
1060
1078
  _is_skills_integration = resolved_integration.is_skills_mode(
1061
1079
  integration_parsed_options or None, project_root=project_path
1062
1080
  )
@@ -26,7 +26,7 @@ its configured `--commands-dir` in both commands and skills layouts. Installing
26
26
  `speckit-github-taskstoissues/SKILL.md` in skills mode. The core
27
27
  `speckit.taskstoissues` command is deprecated but remains available during migration.
28
28
  Use this extension's recommended command instead. See
29
- [integration-specific options](../../docs/reference/integrations.md#integration-specific-options).
29
+ [integration-specific options](../../docs/integrations/cli.md#integration-specific-options).
30
30
 
31
31
  ## Removal
32
32
 
@@ -96,7 +96,7 @@ specify integration upgrade <key>
96
96
  Replace `<key>` with the installed integration key, or omit it to upgrade the
97
97
  default integration. This refreshes the core command with the deprecation
98
98
  warning. If locally modified files block the upgrade, inspect those changes
99
- before deciding whether to overwrite them. See [Upgrade an Integration](../../docs/reference/integrations.md#upgrade-an-integration).
99
+ before deciding whether to overwrite them. See [Upgrade an Integration](../../docs/integrations/cli.md#upgrade-an-integration).
100
100
 
101
101
  To migrate, install the extension and use the namespaced command instead:
102
102
 
@@ -3,7 +3,7 @@ schema_version: "1.0"
3
3
  extension:
4
4
  id: github
5
5
  name: "GitHub Integration"
6
- version: "1.0.2"
6
+ version: "1.0.3"
7
7
  description: "GitHub platform integration for Spec Kit - create GitHub issues from a feature's task list"
8
8
  author: spec-kit-core
9
9
  repository: https://github.com/github/spec-kit
@@ -94,7 +94,7 @@ if ($PathsOnly) {
94
94
  }
95
95
 
96
96
  # Validate required directories and files
97
- if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
97
+ if (-not (Test-Path -LiteralPath $paths.FEATURE_DIR -PathType Container)) {
98
98
  [Console]::Error.WriteLine("ERROR: Feature directory not found: $($paths.FEATURE_DIR)")
99
99
  $specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
100
100
  [Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
@@ -118,7 +118,7 @@ if ((Get-Item -LiteralPath $paths.IMPL_PLAN).Length -eq 0) {
118
118
  }
119
119
 
120
120
  # Check for spec.md if required
121
- if ($RequireSpec -and -not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
121
+ if ($RequireSpec -and -not (Test-Path -LiteralPath $paths.FEATURE_SPEC -PathType Leaf)) {
122
122
  [Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
123
123
  $specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
124
124
  [Console]::Error.WriteLine("Run $specifyCommand first to create the feature specification.")
@@ -126,7 +126,7 @@ if ($RequireSpec -and -not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
126
126
  }
127
127
 
128
128
  # Check for tasks.md if required
129
- if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
129
+ if ($RequireTasks -and -not (Test-Path -LiteralPath $paths.TASKS -PathType Leaf)) {
130
130
  [Console]::Error.WriteLine("ERROR: tasks.md not found in $($paths.FEATURE_DIR)")
131
131
  $tasksCommand = Format-SpecKitCommand -CommandName 'tasks' -RepoRoot $paths.REPO_ROOT
132
132
  [Console]::Error.WriteLine("Run $tasksCommand first to create the task list.")
@@ -137,18 +137,18 @@ if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
137
137
  $docs = @()
138
138
 
139
139
  # Always check these optional docs
140
- if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
141
- if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
140
+ if (Test-Path -LiteralPath $paths.RESEARCH) { $docs += 'research.md' }
141
+ if (Test-Path -LiteralPath $paths.DATA_MODEL) { $docs += 'data-model.md' }
142
142
 
143
143
  # Check contracts directory (only if it exists and has files)
144
- if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
144
+ if ((Test-Path -LiteralPath $paths.CONTRACTS_DIR) -and (Get-ChildItem -LiteralPath $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
145
145
  $docs += 'contracts/'
146
146
  }
147
147
 
148
- if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
148
+ if (Test-Path -LiteralPath $paths.QUICKSTART) { $docs += 'quickstart.md' }
149
149
 
150
150
  # Include tasks.md if requested and it exists
151
- if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
151
+ if ($IncludeTasks -and (Test-Path -LiteralPath $paths.TASKS)) {
152
152
  $docs += 'tasks.md'
153
153
  }
154
154
 
@@ -246,7 +246,7 @@ function Get-FeaturePathsEnv {
246
246
 
247
247
  function Test-FileExists {
248
248
  param([string]$Path, [string]$Description)
249
- if (Test-Path -Path $Path -PathType Leaf) {
249
+ if (Test-Path -LiteralPath $Path -PathType Leaf) {
250
250
  Write-Output " [OK] $Description"
251
251
  return $true
252
252
  } else {
@@ -263,7 +263,7 @@ function Test-DirHasFiles {
263
263
  # only contents are subdirectories (e.g. contracts/v1/openapi.yaml) as
264
264
  # non-empty like bash check_dir. Filtering out subdirectories would
265
265
  # mis-report such a directory as empty.
266
- if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Select-Object -First 1)) {
266
+ if ((Test-Path -LiteralPath $Path -PathType Container) -and (Get-ChildItem -LiteralPath $Path -ErrorAction SilentlyContinue | Select-Object -First 1)) {
267
267
  Write-Output " [OK] $Description"
268
268
  return $true
269
269
  } else {
@@ -32,14 +32,14 @@ if (-not $paths) {
32
32
  exit 1
33
33
  }
34
34
 
35
- if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
35
+ if (-not (Test-Path -LiteralPath $paths.IMPL_PLAN -PathType Leaf)) {
36
36
  [Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
37
37
  $planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
38
38
  [Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
39
39
  exit 1
40
40
  }
41
41
 
42
- if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
42
+ if (-not (Test-Path -LiteralPath $paths.FEATURE_SPEC -PathType Leaf)) {
43
43
  [Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
44
44
  $specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
45
45
  [Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
@@ -48,12 +48,12 @@ if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
48
48
 
49
49
  # Build available docs list
50
50
  $docs = @()
51
- if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
52
- if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
53
- if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
51
+ if (Test-Path -LiteralPath $paths.RESEARCH) { $docs += 'research.md' }
52
+ if (Test-Path -LiteralPath $paths.DATA_MODEL) { $docs += 'data-model.md' }
53
+ if ((Test-Path -LiteralPath $paths.CONTRACTS_DIR) -and (Get-ChildItem -LiteralPath $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
54
54
  $docs += 'contracts/'
55
55
  }
56
- if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
56
+ if (Test-Path -LiteralPath $paths.QUICKSTART) { $docs += 'quickstart.md' }
57
57
 
58
58
  # Resolve tasks template through override stack
59
59
  $tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
@@ -28,7 +28,8 @@ except ImportError: # pragma: no cover - direct execution from unusual cwd
28
28
 
29
29
 
30
30
  def _json_line(payload: object) -> str:
31
- return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
31
+ # ASCII escapes keep the JSON intact on a legacy Windows stdout code page.
32
+ return json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n"
32
33
 
33
34
 
34
35
  HELP_TEXT = """Usage: check_prerequisites.py [OPTIONS]
@@ -50,7 +50,8 @@ def main(argv: list[str] | None = None) -> int:
50
50
  "TEMPLATE_NAME": args.template_name,
51
51
  "TEMPLATE_CONTENT": content,
52
52
  },
53
- ensure_ascii=False,
53
+ # ASCII escapes keep the JSON intact on a legacy Windows stdout code page.
54
+ ensure_ascii=True,
54
55
  separators=(",", ":"),
55
56
  )
56
57
  )
@@ -23,7 +23,8 @@ except ImportError: # pragma: no cover - direct execution from unusual cwd
23
23
 
24
24
 
25
25
  def _json_line(payload: object) -> str:
26
- return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
26
+ # ASCII escapes keep the JSON intact on a legacy Windows stdout code page.
27
+ return json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n"
27
28
 
28
29
 
29
30
  def _help_text(argv0: str) -> str:
@@ -29,7 +29,8 @@ except ImportError: # pragma: no cover - direct execution from unusual cwd
29
29
 
30
30
 
31
31
  def _json_line(payload: object) -> str:
32
- return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
32
+ # ASCII escapes keep the JSON intact on a legacy Windows stdout code page.
33
+ return json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n"
33
34
 
34
35
 
35
36
  def _help_text(argv0: str) -> str:
@@ -14,6 +14,7 @@ API, Typer application, and registration.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import hashlib
17
18
  import json
18
19
  import logging
19
20
  import os
@@ -1232,17 +1233,6 @@ def _shell_quote(value: str, target_os: str) -> str:
1232
1233
  return shlex.quote(value)
1233
1234
 
1234
1235
 
1235
- def _vibe_target_os() -> str:
1236
- """Quoting target for Vibe hook commands.
1237
-
1238
- Vibe launches hooks with ``asyncio.create_subprocess_shell`` — the host's
1239
- native shell: POSIX ``sh`` on Unix, ``cmd.exe`` (%COMSPEC%) on Windows,
1240
- where POSIX single-quoting is not quoting at all and an interpreter or
1241
- dispatcher path containing spaces would split.
1242
- """
1243
- return "cmd" if os.name == "nt" else "host"
1244
-
1245
-
1246
1236
  def _dispatcher_command(
1247
1237
  integration: IntegrationBase,
1248
1238
  project_root: Path,
@@ -1356,6 +1346,8 @@ def install_integration_events(
1356
1346
  project_root: Path,
1357
1347
  manifest: IntegrationManifest,
1358
1348
  events: ResolvedEvents,
1349
+ *,
1350
+ force: bool = False,
1359
1351
  ) -> list[Path]:
1360
1352
  """Generate dispatcher, merge native config, return created files.
1361
1353
 
@@ -1388,7 +1380,7 @@ def install_integration_events(
1388
1380
  # new manifest no longer claims it and stale cleanup excludes it).
1389
1381
  if not filtered:
1390
1382
  _remove_native_event_hooks(integration, project_root, manifest)
1391
- _cleanup_shared_dispatcher(integration, project_root, manifest)
1383
+ _cleanup_shared_dispatcher(integration, project_root, manifest, force=force)
1392
1384
  return []
1393
1385
 
1394
1386
  created: list[Path] = []
@@ -1506,51 +1498,29 @@ def install_integration_events(
1506
1498
  created.append(config_path)
1507
1499
 
1508
1500
  elif fmt == "toml-vibe":
1509
- # Vibe hooks.toml custom merge. Flat [[hooks]] array; Vibe's
1510
- # HookConfig schema is name/type/command/match/timeout, with type
1511
- # limited to "pre_tool" | "post_tool" | "post_agent". Hook names must
1512
- # be unique (Vibe silently drops duplicates by name), so a per-file
1513
- # counter suffix disambiguates handlers whose commands share a final
1514
- # segment (e.g. speckit.a.validate vs speckit.b.validate).
1515
- lines: list[str] = []
1516
- used_names: set[str] = set()
1517
- for ev, handlers in filtered.items():
1518
- native = canonical_to_native[ev]
1519
- for cfg in handlers:
1520
- command = cfg.get("command", "")
1521
- dispatcher_cmd = _dispatcher_command(
1522
- integration, project_root, command, ev,
1523
- target_os=_vibe_target_os(),
1524
- timeout_seconds=cfg.get("timeout", 60),
1525
- )
1526
- command_stem = command.split('.')[-1] if command else "unknown"
1527
- command_stem = re.sub(r'[^A-Za-z0-9_-]+', '-', command_stem) or "unknown"
1528
- base_name = f"speckit-{native}-{command_stem}"
1529
- hook_name = base_name
1530
- suffix = 2
1531
- while hook_name in used_names:
1532
- hook_name = f"{base_name}-{suffix}"
1533
- suffix += 1
1534
- used_names.add(hook_name)
1535
- lines.append("[[hooks]]")
1536
- lines.append(f'name = {_toml_quote(hook_name)}')
1537
- lines.append(f'type = {_toml_quote(native)}')
1538
- # Vibe's field is `match` (fnmatch glob, or `re:`-prefixed
1539
- # regex, case-insensitive) and it is only valid on tool
1540
- # hooks — HookConfig rejects `match` on post_agent. Canonical
1541
- # matchers are Claude-style regexes ("Edit|Write"), so
1542
- # non-wildcard matchers are emitted as `re:` patterns.
1543
- matcher = cfg.get("matcher", "*")
1544
- if matcher and matcher != "*" and native in ("pre_tool", "post_tool"):
1545
- lines.append(f'match = {_toml_quote("re:" + matcher)}')
1546
- lines.append(f'command = {_toml_quote(dispatcher_cmd)}')
1547
- lines.append(f'timeout = {_native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER)}')
1548
- lines.append('speckit_marker = true')
1549
- lines.append('')
1550
- # S5: only track when the merge wrote (skips on unreadable file).
1551
- if _merge_vibe_toml_fragment(config_path, "\n".join(lines)):
1552
- rel = str(config_path.relative_to(project_root))
1553
- manifest.record_existing(rel, ownership="partial")
1501
+ # Vibe owns its native TOML schema and its managed-entry boundaries.
1502
+ # Shared events deliberately supplies only dispatcher construction and
1503
+ # manifest handling, keeping this a narrow integration-specific seam.
1504
+ merge_vibe_hooks = getattr(integration, "merge_vibe_event_hooks", None)
1505
+ if not callable(merge_vibe_hooks):
1506
+ raise TypeError("toml-vibe integrations must implement merge_vibe_event_hooks")
1507
+ if merge_vibe_hooks(
1508
+ project_root,
1509
+ filtered,
1510
+ build_dispatcher_command=lambda command, event, target_os, timeout: _dispatcher_command(
1511
+ integration,
1512
+ project_root,
1513
+ command,
1514
+ event,
1515
+ target_os=target_os,
1516
+ timeout_seconds=timeout,
1517
+ ),
1518
+ native_timeout=lambda seconds: _native_timeout(integration, seconds),
1519
+ ensure_safe_destination=_ensure_safe_destination,
1520
+ ):
1521
+ rel = config_path.relative_to(project_root).as_posix()
1522
+ manifest.record_existing(rel)
1523
+ manifest.set_ownership(rel, "partial")
1554
1524
  created.append(config_path)
1555
1525
 
1556
1526
  elif fmt == "json-flat":
@@ -1673,7 +1643,13 @@ def _remove_native_event_hooks(
1673
1643
  elif fmt == "toml":
1674
1644
  _remove_toml_entries(config_path)
1675
1645
  elif fmt == "toml-vibe":
1676
- _remove_vibe_toml_entries(config_path)
1646
+ remove_vibe_hooks = getattr(integration, "remove_vibe_event_hooks", None)
1647
+ if not callable(remove_vibe_hooks):
1648
+ raise TypeError("toml-vibe integrations must implement remove_vibe_event_hooks")
1649
+ remove_vibe_hooks(
1650
+ project_root,
1651
+ ensure_safe_destination=_ensure_safe_destination,
1652
+ )
1677
1653
  elif fmt in ("json-nested", "json-flat"):
1678
1654
  _remove_json_entries(config_path)
1679
1655
  elif fmt == "json-root-nested":
@@ -1717,51 +1693,74 @@ def _other_event_integrations_reference_dispatcher(
1717
1693
 
1718
1694
 
1719
1695
  def _cleanup_shared_dispatcher(
1720
- integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest
1696
+ integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest,
1697
+ *,
1698
+ force: bool = False,
1721
1699
  ) -> None:
1722
- """Drop this integration's manifest claim on the shared dispatcher and
1723
- delete the file only when no other installed event-capable integration
1724
- still references it (#10, S3).
1725
-
1726
- The manifest.remove() runs in both branches (S1): if we retain the file
1727
- but leave it tracked, the subsequent manifest.uninstall() in teardown()
1728
- sees the matching hash and deletes the file another integration still
1729
- depends on. Used by full teardown and by the empty-resolved-map install
1730
- path so an ``--events false`` upgrade of the last event integration
1731
- doesn't orphan ``.specify/events.py`` permanently (S3).
1732
- """
1700
+ """Release this integration's claim; delete only its last managed copy."""
1701
+ from ..integrations.manifest import IntegrationManifest
1702
+
1733
1703
  dispatcher_rel = EVENTS_DISPATCHER_REL
1734
- # Drop this integration's manifest claim if present. The remove() is
1735
- # conditional (S1): an upgrade passes a *fresh* manifest that may never
1736
- # have claimed the dispatcher, so the key may be absent — that's a no-op.
1704
+ owner = manifest
1705
+ # Upgrades emit into a fresh manifest before replacing the installed one.
1706
+ # Only this integration's prior manifest can supply its missing claim.
1707
+ if dispatcher_rel not in owner.files:
1708
+ try:
1709
+ owner = IntegrationManifest.load(integration.key, project_root)
1710
+ except FileNotFoundError:
1711
+ owner = manifest
1712
+ except (OSError, ValueError) as exc:
1713
+ logger.warning(
1714
+ "Could not read prior dispatcher manifest for '%s'; preserving %s: %s",
1715
+ integration.key, project_root / dispatcher_rel, exc,
1716
+ )
1717
+ return
1718
+ expected_hash = owner.files.get(dispatcher_rel)
1719
+ recovered = owner.is_recovered(dispatcher_rel)
1720
+ partial = owner.ownership_modes.get(dispatcher_rel) == "partial"
1721
+
1722
+ # Release even retained claims so manifest-only teardown cannot delete a
1723
+ # user-owned copy or a dispatcher still used by another integration.
1737
1724
  if dispatcher_rel in manifest.files:
1738
1725
  manifest.remove(dispatcher_rel)
1739
- # S2: run the no-other-references deletion independently of whether the
1740
- # new manifest currently contains the key. An ``integration upgrade
1741
- # --events false`` passes a fresh manifest that never recorded the
1742
- # dispatcher, so gating the deletion on its presence orphans the file the
1743
- # old on-disk manifest owned — and stale cleanup excludes it (C3). If no
1744
- # other installed event-capable integration references the dispatcher,
1745
- # delete it; otherwise leave it for them.
1746
- if not _other_event_integrations_reference_dispatcher(project_root, integration.key):
1747
- dispatcher_path = project_root / dispatcher_rel
1748
- if dispatcher_path.exists():
1749
- _ensure_safe_destination(dispatcher_path)
1750
- _unlink_file(dispatcher_path, missing_ok=True)
1726
+ if not expected_hash or recovered or partial:
1727
+ return
1728
+ if _other_event_integrations_reference_dispatcher(project_root, integration.key):
1729
+ return
1730
+ dispatcher_path = project_root / dispatcher_rel
1731
+ if dispatcher_path.exists():
1732
+ _ensure_safe_destination(dispatcher_path)
1733
+ if not force:
1734
+ try:
1735
+ content = dispatcher_path.read_bytes()
1736
+ except OSError as exc:
1737
+ logger.warning("Preserving unreadable dispatcher %s: %s", dispatcher_path, exc)
1738
+ return
1739
+ # Another integration may have regenerated the shared file since
1740
+ # this manifest recorded it. Accept only exact generated LF/CRLF.
1741
+ matches = hashlib.sha256(content).hexdigest() == expected_hash or content in (
1742
+ _EVENTS_DISPATCHER_TEMPLATE.encode("utf-8"),
1743
+ _EVENTS_DISPATCHER_TEMPLATE.replace("\n", "\r\n").encode("utf-8"),
1744
+ )
1745
+ if not matches:
1746
+ logger.warning("Preserving modified dispatcher: %s", dispatcher_path)
1747
+ return
1748
+ _unlink_file(dispatcher_path, missing_ok=True)
1751
1749
 
1752
1750
 
1753
1751
  def remove_integration_events(
1754
- integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest
1752
+ integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest,
1753
+ *,
1754
+ force: bool = False,
1755
1755
  ) -> None:
1756
1756
  """Remove Specify-authored event entries from native config.
1757
1757
 
1758
- The shared ``.specify/events.py`` dispatcher is deleted only when no other
1759
- installed event-capable integration still references it (#10); otherwise
1760
- it is left in place so multi-install setups don't lose the dispatcher
1761
- mid-stream.
1758
+ The shared dispatcher is deleted only if this integration owns it and no
1759
+ other installed integration references it. Modified copies require force;
1760
+ unowned or recovered copies are always retained.
1762
1761
  """
1763
1762
  _remove_native_event_hooks(integration, project_root, manifest)
1764
- _cleanup_shared_dispatcher(integration, project_root, manifest)
1763
+ _cleanup_shared_dispatcher(integration, project_root, manifest, force=force)
1765
1764
 
1766
1765
  # Clean up opencode TS plugin (owned solely by the opencode integration).
1767
1766
  if integration.key == "opencode":
@@ -2208,42 +2207,6 @@ def _merge_toml_fragment(dst: Path, fragment: str) -> bool:
2208
2207
  return True
2209
2208
 
2210
2209
 
2211
- def _merge_vibe_toml_fragment(dst: Path, fragment: str) -> bool:
2212
- """Merge Specify-owned Vibe TOML hook entries into *dst*, regenerating the file.
2213
-
2214
- Vibe uses a flat [[hooks]] array with type/matcher/command fields.
2215
- This removes any existing Specify-marked hooks and appends the new fragment.
2216
- An unreadable or undecodable pre-existing file aborts the merge instead
2217
- of discarding the user's bytes, mirroring ``_load_user_json`` (#22).
2218
- Returns False when skipped so callers avoid tracking the untouched file
2219
- (S5).
2220
- """
2221
- _ensure_safe_destination(dst)
2222
- existing = ""
2223
- if dst.exists():
2224
- try:
2225
- existing = dst.read_text(encoding="utf-8")
2226
- except (OSError, UnicodeDecodeError) as exc:
2227
- logger.warning(
2228
- "Could not read %s (it may be unreadable or not UTF-8); "
2229
- "skipping event-config merge to preserve user content.",
2230
- dst,
2231
- )
2232
- logger.debug("Read error detail: %s", exc)
2233
- return False
2234
- # Remove existing Specify-marked [[hooks]] blocks
2235
- # Match [[hooks]] ... speckit_marker = true (with any content in between)
2236
- existing = re.sub(
2237
- r'\[\[hooks\]\]\n(?:(?!\[\[hooks\]\]).)*?speckit_marker = true\n*',
2238
- "",
2239
- existing,
2240
- flags=re.DOTALL,
2241
- )
2242
- dst.parent.mkdir(parents=True, exist_ok=True)
2243
- _write_text(dst, existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
2244
- return True
2245
-
2246
-
2247
2210
  def _remove_toml_entries(dst: Path) -> bool:
2248
2211
  """Remove Specify-marked TOML entries; delete the file if now empty (#14).
2249
2212
 
@@ -2295,43 +2258,6 @@ def _remove_toml_entries(dst: Path) -> bool:
2295
2258
  return False
2296
2259
 
2297
2260
 
2298
- def _remove_vibe_toml_entries(dst: Path) -> bool:
2299
- """Remove Specify-marked Vibe TOML hook entries; delete the file if now empty.
2300
-
2301
- Returns True if the file was deleted (no user content remained).
2302
- """
2303
- if not dst.exists():
2304
- return False
2305
- _ensure_safe_destination(dst)
2306
- try:
2307
- existing = dst.read_text(encoding="utf-8")
2308
- except (OSError, UnicodeDecodeError) as exc:
2309
- logger.warning(
2310
- "Could not read %s (it may be unreadable or not UTF-8); "
2311
- "skipping event-config cleanup to preserve user content.",
2312
- dst,
2313
- )
2314
- logger.debug("Read error detail: %s", exc)
2315
- return False
2316
- # Remove Specify-marked [[hooks]] blocks
2317
- cleaned = re.sub(
2318
- r'\[\[hooks\]\]\n(?:(?!\[\[hooks\]\]).)*?speckit_marker = true\n*',
2319
- "",
2320
- existing,
2321
- flags=re.DOTALL,
2322
- )
2323
- # If only whitespace/comments remain, the file had no user content
2324
- stripped = "\n".join(
2325
- line for line in cleaned.splitlines()
2326
- if line.strip() and not line.strip().startswith("#")
2327
- )
2328
- if not stripped:
2329
- _unlink_file(dst, missing_ok=True)
2330
- return True
2331
- _write_text(dst, cleaned, encoding="utf-8")
2332
- return False
2333
-
2334
-
2335
2261
  def _merge_copilot_json(dst: Path, new_hooks: dict[str, list]) -> bool:
2336
2262
  """Merge Specify-owned hooks into Copilot's dedicated hooks JSON (#8).
2337
2263