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.
- specify_cli/_install_ignore.py +87 -0
- specify_cli/command_check.py +2 -2
- specify_cli/command_init.py +18 -0
- specify_cli/core_pack/extensions/github/README.md +2 -2
- specify_cli/core_pack/extensions/github/extension.yml +1 -1
- specify_cli/core_pack/scripts/powershell/check-prerequisites.ps1 +8 -8
- specify_cli/core_pack/scripts/powershell/common.ps1 +2 -2
- specify_cli/core_pack/scripts/powershell/setup-tasks.ps1 +6 -6
- specify_cli/core_pack/scripts/python/check_prerequisites.py +2 -1
- specify_cli/core_pack/scripts/python/resolve_template.py +2 -1
- specify_cli/core_pack/scripts/python/setup_plan.py +2 -1
- specify_cli/core_pack/scripts/python/setup_tasks.py +2 -1
- specify_cli/events/__init__.py +90 -164
- specify_cli/extensions/__init__.py +2 -62
- specify_cli/extensions/catalog/command_remove.py +8 -1
- specify_cli/integrations/__init__.py +2 -0
- specify_cli/integrations/base.py +25 -10
- specify_cli/integrations/command_list.py +1 -1
- specify_cli/integrations/command_upgrade.py +1 -0
- specify_cli/integrations/copilot/__init__.py +2 -1
- specify_cli/integrations/kimi/__init__.py +29 -0
- specify_cli/integrations/none/__init__.py +29 -0
- specify_cli/integrations/pi/__init__.py +34 -0
- specify_cli/integrations/vibe/__init__.py +394 -1
- specify_cli/presets/_manager.py +5 -1
- specify_cli/workflows/_mcp.py +377 -0
- specify_cli/workflows/engine.py +9 -1
- {specify_cli-1.2.2.dist-info → specify_cli-1.2.3.dist-info}/METADATA +69 -48
- {specify_cli-1.2.2.dist-info → specify_cli-1.2.3.dist-info}/RECORD +32 -29
- {specify_cli-1.2.2.dist-info → specify_cli-1.2.3.dist-info}/WHEEL +0 -0
- {specify_cli-1.2.2.dist-info → specify_cli-1.2.3.dist-info}/entry_points.txt +0 -0
- {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
|
specify_cli/command_check.py
CHANGED
|
@@ -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, "
|
|
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, "
|
|
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")
|
specify_cli/command_init.py
CHANGED
|
@@ -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/
|
|
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/
|
|
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.
|
|
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 -
|
|
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 -
|
|
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 -
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
specify_cli/events/__init__.py
CHANGED
|
@@ -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
|
|
1510
|
-
#
|
|
1511
|
-
#
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
1723
|
-
|
|
1724
|
-
|
|
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
|
-
|
|
1735
|
-
#
|
|
1736
|
-
#
|
|
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
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
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
|
|
1759
|
-
installed
|
|
1760
|
-
|
|
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
|
|