specpro-cli 0.1.0__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.
- specpro_cli/__init__.py +16 -0
- specpro_cli/assets/commands/specpro.analyze.md +1102 -0
- specpro_cli/assets/commands/specpro.checklist.md +335 -0
- specpro_cli/assets/commands/specpro.clarify.md +581 -0
- specpro_cli/assets/commands/specpro.constitution.md +488 -0
- specpro_cli/assets/commands/specpro.feature.md +115 -0
- specpro_cli/assets/commands/specpro.implement.md +1881 -0
- specpro_cli/assets/commands/specpro.manual-test.md +206 -0
- specpro_cli/assets/commands/specpro.plan.md +3284 -0
- specpro_cli/assets/commands/specpro.qc.md +1489 -0
- specpro_cli/assets/commands/specpro.scenarios.md +154 -0
- specpro_cli/assets/commands/specpro.specify.md +1449 -0
- specpro_cli/assets/commands/specpro.status.md +863 -0
- specpro_cli/assets/commands/specpro.tasks.md +1207 -0
- specpro_cli/assets/commands/specpro.test-implement.md +462 -0
- specpro_cli/assets/commands/specpro.test-plan.md +383 -0
- specpro_cli/assets/commands/specpro.user-manual.md +178 -0
- specpro_cli/assets/scripts/bash/check-anti-coupling.sh +293 -0
- specpro_cli/assets/scripts/bash/check-prerequisites.sh +176 -0
- specpro_cli/assets/scripts/bash/common.sh +88 -0
- specpro_cli/assets/scripts/bash/create-new-feature.sh +336 -0
- specpro_cli/assets/scripts/bash/qc-auto-fix.sh +121 -0
- specpro_cli/assets/scripts/bash/setup-plan.sh +60 -0
- specpro_cli/assets/scripts/bash/verify-cumulative-records.sh +203 -0
- specpro_cli/assets/scripts/bash/verify-deliverables-tracked.sh +147 -0
- specpro_cli/assets/scripts/bash/verify-deployment.sh +239 -0
- specpro_cli/assets/scripts/bash/verify-frontmatter-yaml.sh +63 -0
- specpro_cli/assets/scripts/bash/verify-ledger.sh +376 -0
- specpro_cli/assets/scripts/bash/verify-shapes.sh +1082 -0
- specpro_cli/assets/scripts/git-hooks/pre-commit +243 -0
- specpro_cli/assets/scripts/install-git-hooks.sh +67 -0
- specpro_cli/assets/scripts/powershell/check-anti-coupling.ps1 +249 -0
- specpro_cli/assets/scripts/powershell/check-prerequisites.ps1 +148 -0
- specpro_cli/assets/scripts/powershell/common.ps1 +95 -0
- specpro_cli/assets/scripts/powershell/create-new-feature.ps1 +229 -0
- specpro_cli/assets/scripts/powershell/qc-auto-fix.ps1 +110 -0
- specpro_cli/assets/scripts/powershell/setup-plan.ps1 +61 -0
- specpro_cli/assets/scripts/powershell/verify-cumulative-records.ps1 +133 -0
- specpro_cli/assets/scripts/powershell/verify-deliverables-tracked.ps1 +112 -0
- specpro_cli/assets/scripts/powershell/verify-deployment.ps1 +278 -0
- specpro_cli/assets/scripts/powershell/verify-frontmatter-yaml.ps1 +56 -0
- specpro_cli/assets/scripts/powershell/verify-ledger.ps1 +383 -0
- specpro_cli/assets/scripts/powershell/verify-shapes.ps1 +978 -0
- specpro_cli/assets/templates/agent-context-template.md +49 -0
- specpro_cli/assets/templates/assumptions-template.md +248 -0
- specpro_cli/assets/templates/checklist-template.md +40 -0
- specpro_cli/assets/templates/clarifications-template.md +155 -0
- specpro_cli/assets/templates/constitution-template.md +50 -0
- specpro_cli/assets/templates/feature-spec-template.md +66 -0
- specpro_cli/assets/templates/plan-overview-template.md +150 -0
- specpro_cli/assets/templates/plan-template.md +387 -0
- specpro_cli/assets/templates/protocol-golden-bytes-guide.md +195 -0
- specpro_cli/assets/templates/requirements-template.md +356 -0
- specpro_cli/assets/templates/spec-template.md +267 -0
- specpro_cli/assets/templates/tasks-template.md +252 -0
- specpro_cli/assets/templates/test-tasks-template.md +174 -0
- specpro_cli/cli/__init__.py +5 -0
- specpro_cli/cli/cmd_init.py +416 -0
- specpro_cli/cli/cmd_remove.py +122 -0
- specpro_cli/cli/entry.py +181 -0
- specpro_cli/integrations/__init__.py +36 -0
- specpro_cli/integrations/base.py +601 -0
- specpro_cli/integrations/claude/__init__.py +101 -0
- specpro_cli/integrations/copilot/__init__.py +153 -0
- specpro_cli/integrations/cursor_agent/__init__.py +51 -0
- specpro_cli/integrations/gemini/__init__.py +44 -0
- specpro_cli/integrations/opencode/__init__.py +48 -0
- specpro_cli/integrations/qodercli/__init__.py +54 -0
- specpro_cli/integrations/registry.py +88 -0
- specpro_cli/packaged/__init__.py +5 -0
- specpro_cli/packaged/sync.py +106 -0
- specpro_cli-0.1.0.dist-info/METADATA +117 -0
- specpro_cli-0.1.0.dist-info/RECORD +76 -0
- specpro_cli-0.1.0.dist-info/WHEEL +4 -0
- specpro_cli-0.1.0.dist-info/entry_points.txt +2 -0
- specpro_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"""`IntegrationBase` + the four format base classes.
|
|
2
|
+
|
|
3
|
+
Architecture ported from spec-kit (validated on 41 agents); vocabulary narrowed to
|
|
4
|
+
specpro's own templates. See `specs/contracts/agent-registry.md` for the declaration
|
|
5
|
+
contract every subclass must satisfy, and `specs/plan.md` -> "Install CLI — Module
|
|
6
|
+
Structure" for where this file sits.
|
|
7
|
+
|
|
8
|
+
Why one file holds all five classes: `specs/plan.md`'s module tree defines
|
|
9
|
+
`integrations/base` as "IntegrationBase + the five format base classes". Splitting
|
|
10
|
+
them would be a design change, and this stage executes the design rather than
|
|
11
|
+
revising it.
|
|
12
|
+
|
|
13
|
+
**What is deliberately NOT ported from spec-kit** (recorded in `specs/research.md`
|
|
14
|
+
-> Addendum): the events/hooks subsystem (`CANONICAL_TO_NATIVE`, `events_config_file`,
|
|
15
|
+
`events_format`) — specpro's contract declares no such fields, and specpro ships no
|
|
16
|
+
hook feature for them to drive. Porting it would add machinery with no consumer.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import shutil
|
|
22
|
+
from abc import ABC
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Sequence
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"IntegrationBase",
|
|
29
|
+
"IntegrationOption",
|
|
30
|
+
"MarkdownIntegration",
|
|
31
|
+
"SkillsIntegration",
|
|
32
|
+
"TomlIntegration",
|
|
33
|
+
"YamlIntegration",
|
|
34
|
+
"rewrite_project_relative_paths",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class IntegrationOption:
|
|
40
|
+
"""A CLI option an integration accepts."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
is_flag: bool = False
|
|
44
|
+
required: bool = False
|
|
45
|
+
default: Any = None
|
|
46
|
+
help: str = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --------------------------------------------------------------------------------------
|
|
50
|
+
# Path rewriting
|
|
51
|
+
# --------------------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
# Literal forms, applied before the regex pass so the regex cannot re-process them.
|
|
54
|
+
_LITERAL_REWRITES: tuple[tuple[str, str], ...] = (
|
|
55
|
+
("../../memory/", ".specpro/memory/"),
|
|
56
|
+
("../../scripts/", ".specpro/scripts/"),
|
|
57
|
+
("../../templates/", ".specpro/templates/"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# ⚠️ Only the unambiguous `../../` literals are rewritten. See the docstring for why the
|
|
61
|
+
# softer "bare top-level reference" pass that spec-kit also carries is deliberately absent.
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def rewrite_project_relative_paths(content: str) -> str:
|
|
65
|
+
"""Rewrite repo-relative `../../<dir>/` references to their `.specpro/`-anchored form.
|
|
66
|
+
|
|
67
|
+
⚠️ **This deliberately implements LESS than the reference implementation**, and the
|
|
68
|
+
reason is measured, not stylistic.
|
|
69
|
+
|
|
70
|
+
spec-kit additionally runs a regex pass that rewrites *bare* top-level references —
|
|
71
|
+
`(^|[\\s`"'(])(?:\\./)?scripts/` and its `memory/` / `templates/` siblings. That
|
|
72
|
+
pattern has no trailing discriminator, so it cannot tell a path from prose. Measured
|
|
73
|
+
against specpro's shipped assets it rewrites:
|
|
74
|
+
|
|
75
|
+
"temporary scripts/programs created during debugging"
|
|
76
|
+
-> "temporary .specpro/scripts/programs created during debugging"
|
|
77
|
+
|
|
78
|
+
— inside `commands/specpro.implement.md`, silently, in a file the user then reads and
|
|
79
|
+
executes. No local rule can separate `scripts/programs` (two English words) from
|
|
80
|
+
`scripts/foo.sh` (a path), so the pass cannot be made safe; it can only be removed.
|
|
81
|
+
|
|
82
|
+
Removing it costs nothing here: **no shipped specpro asset contains a bare
|
|
83
|
+
top-level reference** — specpro's commands were authored against the *install
|
|
84
|
+
target's* layout and already say `.specpro/...`. The pass's only observable effect on
|
|
85
|
+
specpro's assets is corruption. The `../../` literals are unambiguous and are kept.
|
|
86
|
+
|
|
87
|
+
See `specs/research.md` -> Addendum for the full record (chosen · rejected · why).
|
|
88
|
+
"""
|
|
89
|
+
for src, dst in _LITERAL_REWRITES:
|
|
90
|
+
content = content.replace(src, dst)
|
|
91
|
+
|
|
92
|
+
# Idempotency repair: a path that already carried the prefix must not gain a second.
|
|
93
|
+
content = content.replace(".specpro/.specpro/", ".specpro/")
|
|
94
|
+
content = content.replace(".specpro.specpro/", ".specpro/")
|
|
95
|
+
return content
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resource_targets(project_root: Path, folder: str, subdir: str) -> Path:
|
|
99
|
+
"""`<project_root>/<folder>/<subdir>`, with the escape check done by the caller."""
|
|
100
|
+
return project_root / folder / subdir
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def command_stem(template_name: str) -> str:
|
|
104
|
+
"""The bare command name, with the `specpro.` prefix removed.
|
|
105
|
+
|
|
106
|
+
⚠️ **This differs from spec-kit and the difference is load-bearing.** spec-kit's
|
|
107
|
+
source templates are named for the command alone (`plan.md`) and the target name is
|
|
108
|
+
built by *adding* a prefix (`speckit.plan.md`). specpro's source templates **already
|
|
109
|
+
carry the prefix** (`specpro.specify.md`) — they are the distributed artifacts, not
|
|
110
|
+
bare inputs. Re-adding the prefix therefore produces `specpro.specpro.specify.md`.
|
|
111
|
+
Strip first, then rebuild per format.
|
|
112
|
+
"""
|
|
113
|
+
stem = Path(template_name).stem
|
|
114
|
+
prefix = "specpro."
|
|
115
|
+
return stem[len(prefix) :] if stem.startswith(prefix) else stem
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------------------
|
|
119
|
+
# Base
|
|
120
|
+
# --------------------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class IntegrationBase(ABC):
|
|
124
|
+
"""Base for every agent integration.
|
|
125
|
+
|
|
126
|
+
There are **no abstract methods**, deliberately (matching spec-kit, which has zero
|
|
127
|
+
`@abstractmethod` across its integration tree): "required" is enforced at runtime by
|
|
128
|
+
`commands_dest()` / `setup()` raising `ValueError`, and a half-declared subclass is
|
|
129
|
+
caught by the registry's registration check rather than by instantiation.
|
|
130
|
+
|
|
131
|
+
All state is class-level — no `__init__`, no instance attributes. That is what makes
|
|
132
|
+
the declaration-only shape possible: a new agent declares four groups of constants
|
|
133
|
+
and overrides nothing else.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
# --- required declarations ---------------------------------------------------------
|
|
137
|
+
key: str = ""
|
|
138
|
+
config: dict[str, Any] | None = None
|
|
139
|
+
registrar_config: dict[str, Any] | None = None
|
|
140
|
+
|
|
141
|
+
# --- optional declarations ---------------------------------------------------------
|
|
142
|
+
invoke_separator: str = "."
|
|
143
|
+
dev_no_symlink: bool = False
|
|
144
|
+
multi_install_safe: bool = False
|
|
145
|
+
legacy_flat_command_dir: str | None = None
|
|
146
|
+
legacy_flat_command_extension: str | None = None
|
|
147
|
+
|
|
148
|
+
# --- target paths ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def _require_config(self, *, need_folder: bool = True) -> dict[str, Any]:
|
|
151
|
+
if self.config is None:
|
|
152
|
+
raise ValueError(f"{type(self).__name__}: `config` MUST be declared")
|
|
153
|
+
if need_folder and not self.config.get("folder"):
|
|
154
|
+
raise ValueError(f"{type(self).__name__}: `config['folder']` MUST be declared")
|
|
155
|
+
return self.config
|
|
156
|
+
|
|
157
|
+
def commands_dest(self, project_root: Path) -> Path:
|
|
158
|
+
"""Where this integration's command files land.
|
|
159
|
+
|
|
160
|
+
Reads `config` only — the entry layer MUST NOT hardcode any path, and neither
|
|
161
|
+
does this method: the deploy root is whatever the subclass declared.
|
|
162
|
+
"""
|
|
163
|
+
config = self._require_config()
|
|
164
|
+
subdir = config.get("commands_subdir") or "commands"
|
|
165
|
+
return _resource_targets(project_root, config["folder"], subdir)
|
|
166
|
+
|
|
167
|
+
def command_filename(self, template_name: str) -> str:
|
|
168
|
+
"""Target file name for a source template. Format subclasses override.
|
|
169
|
+
|
|
170
|
+
For the Markdown family this is the source name unchanged — specpro's templates
|
|
171
|
+
are distributed artifacts and already carry the `specpro.` prefix.
|
|
172
|
+
"""
|
|
173
|
+
return f"specpro.{command_stem(template_name)}.md"
|
|
174
|
+
|
|
175
|
+
def planned_paths(self, project_root: Path, commands_dir: Path) -> list[Path]:
|
|
176
|
+
"""Every path `setup()` would write, for THIS source set.
|
|
177
|
+
|
|
178
|
+
The uninstall command's removal criterion (install-cli.md → Uninstall
|
|
179
|
+
Contract) is "only paths derivable from the registry" — this method IS that
|
|
180
|
+
derivation, so uninstall removes exactly what install would have written and
|
|
181
|
+
nothing else. The default covers every one-file-per-command format; an agent
|
|
182
|
+
that writes MORE than one file per command overrides this (its `setup` already
|
|
183
|
+
knows the second path — restating the list anywhere else would be a second
|
|
184
|
+
source that drifts).
|
|
185
|
+
"""
|
|
186
|
+
dest_dir = self.commands_dest(project_root)
|
|
187
|
+
return [dest_dir / self.command_filename(src.name) for src in self.list_command_templates(commands_dir)]
|
|
188
|
+
|
|
189
|
+
def deploy_roots(self, project_root: Path) -> list[Path]:
|
|
190
|
+
"""The directories files land DIRECTLY in (never themselves removed).
|
|
191
|
+
|
|
192
|
+
Uninstall may clean up an emptied per-command directory (the skills layout's
|
|
193
|
+
`specpro-<stem>/`), but never a deploy root — the user's own files may live
|
|
194
|
+
there. The default is the one root every one-file format has; an agent with a
|
|
195
|
+
second location overrides this alongside `planned_paths`.
|
|
196
|
+
"""
|
|
197
|
+
return [self.commands_dest(project_root)]
|
|
198
|
+
|
|
199
|
+
# --- invocation --------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
|
202
|
+
"""Build the agent-native invocation string for a specpro command.
|
|
203
|
+
|
|
204
|
+
`specpro.specify` -> `/specpro.specify` (base) or `/specpro-specify` (skills).
|
|
205
|
+
"""
|
|
206
|
+
stem = command_name
|
|
207
|
+
if stem.startswith("specpro."):
|
|
208
|
+
stem = stem[len("specpro.") :]
|
|
209
|
+
invocation = "/specpro" + self.invoke_separator + stem
|
|
210
|
+
return f"{invocation} {args}".strip()
|
|
211
|
+
|
|
212
|
+
# --- template pipeline --------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def arg_placeholder(self) -> str:
|
|
215
|
+
"""The token that `$ARGUMENTS` / `{ARGS}` are replaced with. Overridden per format."""
|
|
216
|
+
if self.registrar_config:
|
|
217
|
+
return self.registrar_config.get("args") or "$ARGUMENTS"
|
|
218
|
+
return "$ARGUMENTS"
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def process_template(
|
|
222
|
+
content: str,
|
|
223
|
+
agent_name: str,
|
|
224
|
+
*,
|
|
225
|
+
arg_placeholder: str = "$ARGUMENTS",
|
|
226
|
+
invoke_separator: str = ".",
|
|
227
|
+
) -> str:
|
|
228
|
+
"""Resolve a command template into its deployable form.
|
|
229
|
+
|
|
230
|
+
Order matters and MUST NOT be rearranged: the argument tokens are replaced
|
|
231
|
+
before the path pass, so an argument value that happens to contain a path-like
|
|
232
|
+
string is not rewritten as though it were part of the template.
|
|
233
|
+
"""
|
|
234
|
+
content = content.replace("{ARGS}", arg_placeholder)
|
|
235
|
+
content = content.replace("$ARGUMENTS", arg_placeholder)
|
|
236
|
+
content = content.replace("__AGENT__", agent_name)
|
|
237
|
+
content = rewrite_project_relative_paths(content)
|
|
238
|
+
return content
|
|
239
|
+
|
|
240
|
+
# --- deploy -------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def list_command_templates(self, commands_dir: Path) -> list[Path]:
|
|
243
|
+
"""Every `.md` template under `commands_dir`, sorted by file name."""
|
|
244
|
+
if not commands_dir.is_dir():
|
|
245
|
+
raise ValueError(f"{type(self).__name__}: commands dir not found: {commands_dir}")
|
|
246
|
+
return sorted(p for p in commands_dir.glob("*.md") if p.is_file())
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def write_file(content: str, dest: Path) -> Path:
|
|
250
|
+
"""Write `content` as bytes.
|
|
251
|
+
|
|
252
|
+
Written as bytes rather than text so no platform newline translation is applied:
|
|
253
|
+
the deployed file must be a pure function of the source template, or the
|
|
254
|
+
reproducibility check (FR-007) compares two different things.
|
|
255
|
+
"""
|
|
256
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
dest.write_bytes(content.replace("\r\n", "\n").encode("utf-8"))
|
|
258
|
+
return dest
|
|
259
|
+
|
|
260
|
+
def setup(
|
|
261
|
+
self, project_root: Path, commands_dir: Path, *, force: bool = False
|
|
262
|
+
) -> tuple[list[Path], list[Path]]:
|
|
263
|
+
"""Deploy this integration's command files. Returns `(written, skipped)`.
|
|
264
|
+
|
|
265
|
+
The default implementation copies templates verbatim, with no placeholder
|
|
266
|
+
processing — a subclass that deploys *derived* artifacts overrides this.
|
|
267
|
+
|
|
268
|
+
⚠️ **The conflict gate lives HERE, per file, not in the entry layer.** The
|
|
269
|
+
contract's fourth validation item (*install-cli.md* → Pre-write Validation) is
|
|
270
|
+
"逐个资产文件检查目标是否已存在" — a per-file decision at the moment of
|
|
271
|
+
writing. Judging it in the entry layer would require the entry layer to
|
|
272
|
+
enumerate every target path of every format — for the companion-file agent that
|
|
273
|
+
enumeration is a second copy of what `setup` already knows, and a format that
|
|
274
|
+
wrote an unplanned file would silently escape the check. The returned `skipped`
|
|
275
|
+
list is the report's only input for that column (`install-cli.md` → Output
|
|
276
|
+
Contract requires 每一个 skipped file named with its reason).
|
|
277
|
+
"""
|
|
278
|
+
dest_dir = self.commands_dest(project_root)
|
|
279
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
written: list[Path] = []
|
|
281
|
+
skipped: list[Path] = []
|
|
282
|
+
for src in self.list_command_templates(commands_dir):
|
|
283
|
+
dest = dest_dir / self.command_filename(src.name)
|
|
284
|
+
if dest.exists() and not force:
|
|
285
|
+
skipped.append(dest)
|
|
286
|
+
continue
|
|
287
|
+
shutil.copy2(src, dest)
|
|
288
|
+
written.append(dest)
|
|
289
|
+
return written, skipped
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --------------------------------------------------------------------------------------
|
|
293
|
+
# Format base classes
|
|
294
|
+
# --------------------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class MarkdownIntegration(IntegrationBase):
|
|
298
|
+
"""`<dir>/<name>.md` — one file per command, placeholder-substituted."""
|
|
299
|
+
|
|
300
|
+
def setup(
|
|
301
|
+
self, project_root: Path, commands_dir: Path, *, force: bool = False
|
|
302
|
+
) -> tuple[list[Path], list[Path]]:
|
|
303
|
+
dest_dir = self.commands_dest(project_root)
|
|
304
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
305
|
+
written: list[Path] = []
|
|
306
|
+
skipped: list[Path] = []
|
|
307
|
+
placeholder = self.arg_placeholder()
|
|
308
|
+
for src in self.list_command_templates(commands_dir):
|
|
309
|
+
dest = dest_dir / self.command_filename(src.name)
|
|
310
|
+
if dest.exists() and not force:
|
|
311
|
+
skipped.append(dest)
|
|
312
|
+
continue
|
|
313
|
+
processed = self.process_template(
|
|
314
|
+
src.read_text(encoding="utf-8"),
|
|
315
|
+
self.key,
|
|
316
|
+
arg_placeholder=placeholder,
|
|
317
|
+
invoke_separator=self.invoke_separator,
|
|
318
|
+
)
|
|
319
|
+
written.append(self.write_file(processed, dest))
|
|
320
|
+
return written, skipped
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class TomlIntegration(IntegrationBase):
|
|
324
|
+
"""`<dir>/<name>.toml` — the Markdown body repackaged as a TOML `prompt` scalar."""
|
|
325
|
+
|
|
326
|
+
def command_filename(self, template_name: str) -> str:
|
|
327
|
+
return f"specpro.{command_stem(template_name)}.toml"
|
|
328
|
+
|
|
329
|
+
def arg_placeholder(self) -> str:
|
|
330
|
+
# TOML consumers substitute `{{args}}`, not the shell-style token.
|
|
331
|
+
if self.registrar_config:
|
|
332
|
+
return self.registrar_config.get("args") or "{{args}}"
|
|
333
|
+
return "{{args}}"
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def split_frontmatter(content: str) -> tuple[str, str]:
|
|
337
|
+
"""`(frontmatter_body, document_body)`; `("", content)` when there is none.
|
|
338
|
+
|
|
339
|
+
Line-anchored rather than `split("---", 2)`: a frontmatter value may itself
|
|
340
|
+
contain `---`, and splitting on the literal truncates it mid-value.
|
|
341
|
+
"""
|
|
342
|
+
lines = content.split("\n")
|
|
343
|
+
if not lines or lines[0].strip() != "---":
|
|
344
|
+
return "", content
|
|
345
|
+
for idx in range(1, len(lines)):
|
|
346
|
+
if lines[idx].strip() == "---":
|
|
347
|
+
return "\n".join(lines[1:idx]), "\n".join(lines[idx + 1 :])
|
|
348
|
+
return "", content
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def extract_description(frontmatter: str) -> str:
|
|
352
|
+
"""The `description:` value, or `""`.
|
|
353
|
+
|
|
354
|
+
Parsed from the raw frontmatter so block scalars (`|` / `>`) keep their meaning.
|
|
355
|
+
|
|
356
|
+
⚠️ **A missing `yaml` degrades to `""` rather than raising** — this is the
|
|
357
|
+
fallback installer's dependency contract (T032): an environment without the
|
|
358
|
+
third-party stack can still install, at the honest cost of a description-less
|
|
359
|
+
header (the skills frontmatter then carries its `specpro: <stem> workflow`
|
|
360
|
+
placeholder). Raising here would make the FIRST import failure abort an install
|
|
361
|
+
that everything else can complete.
|
|
362
|
+
"""
|
|
363
|
+
if not frontmatter.strip():
|
|
364
|
+
return ""
|
|
365
|
+
try:
|
|
366
|
+
import yaml
|
|
367
|
+
except ImportError:
|
|
368
|
+
return ""
|
|
369
|
+
try:
|
|
370
|
+
data = yaml.safe_load(frontmatter)
|
|
371
|
+
except yaml.YAMLError:
|
|
372
|
+
return ""
|
|
373
|
+
if isinstance(data, dict) and isinstance(data.get("description"), str):
|
|
374
|
+
return data["description"]
|
|
375
|
+
return ""
|
|
376
|
+
|
|
377
|
+
@staticmethod
|
|
378
|
+
def render_toml_string(value: str) -> str:
|
|
379
|
+
"""Render `value` as a TOML string, choosing the cheapest safe form."""
|
|
380
|
+
if "\n" not in value and "\r" not in value:
|
|
381
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
382
|
+
escaped = value.replace("\\", "\\\\")
|
|
383
|
+
if '"""' not in escaped:
|
|
384
|
+
if escaped.endswith('"'):
|
|
385
|
+
# A value ending in a quote would close the multiline delimiter.
|
|
386
|
+
return '"""\n' + escaped + '\\\n"""'
|
|
387
|
+
return '"""\n' + escaped + '"""'
|
|
388
|
+
if "'''" not in value and not value.endswith("'"):
|
|
389
|
+
return "'''\n" + value + "'''"
|
|
390
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'
|
|
391
|
+
|
|
392
|
+
@classmethod
|
|
393
|
+
def render_toml(cls, description: str, body: str) -> str:
|
|
394
|
+
"""`description` line, blank line, `prompt` block. Empty description omits its line."""
|
|
395
|
+
parts: list[str] = []
|
|
396
|
+
if description:
|
|
397
|
+
parts.append(f"description = {cls.render_toml_string(description)}")
|
|
398
|
+
parts.append("")
|
|
399
|
+
parts.append(f"prompt = {cls.render_toml_string(body.rstrip(chr(10)))}")
|
|
400
|
+
return "\n".join(parts) + "\n"
|
|
401
|
+
|
|
402
|
+
def setup(
|
|
403
|
+
self, project_root: Path, commands_dir: Path, *, force: bool = False
|
|
404
|
+
) -> tuple[list[Path], list[Path]]:
|
|
405
|
+
dest_dir = self.commands_dest(project_root)
|
|
406
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
407
|
+
written: list[Path] = []
|
|
408
|
+
skipped: list[Path] = []
|
|
409
|
+
placeholder = self.arg_placeholder()
|
|
410
|
+
for src in self.list_command_templates(commands_dir):
|
|
411
|
+
dest = dest_dir / self.command_filename(src.name)
|
|
412
|
+
if dest.exists() and not force:
|
|
413
|
+
skipped.append(dest)
|
|
414
|
+
continue
|
|
415
|
+
raw = src.read_text(encoding="utf-8")
|
|
416
|
+
frontmatter, _ = self.split_frontmatter(raw)
|
|
417
|
+
description = self.extract_description(frontmatter)
|
|
418
|
+
processed = self.process_template(
|
|
419
|
+
raw, self.key, arg_placeholder=placeholder, invoke_separator=self.invoke_separator
|
|
420
|
+
)
|
|
421
|
+
_, body = self.split_frontmatter(processed)
|
|
422
|
+
written.append(self.write_file(self.render_toml(description, body), dest))
|
|
423
|
+
return written, skipped
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class YamlIntegration(IntegrationBase):
|
|
427
|
+
"""`<dir>/<name>.yaml` — header mapping plus the body as a block scalar."""
|
|
428
|
+
|
|
429
|
+
def command_filename(self, template_name: str) -> str:
|
|
430
|
+
return f"specpro.{command_stem(template_name)}.yaml"
|
|
431
|
+
|
|
432
|
+
def arg_placeholder(self) -> str:
|
|
433
|
+
if self.registrar_config:
|
|
434
|
+
return self.registrar_config.get("args") or "{{args}}"
|
|
435
|
+
return "{{args}}"
|
|
436
|
+
|
|
437
|
+
@staticmethod
|
|
438
|
+
def split_frontmatter(content: str) -> tuple[str, str]:
|
|
439
|
+
return TomlIntegration.split_frontmatter(content)
|
|
440
|
+
|
|
441
|
+
@staticmethod
|
|
442
|
+
def render_yaml(description: str, body: str, source_name: str) -> str:
|
|
443
|
+
"""Header, then the body as a literal block scalar.
|
|
444
|
+
|
|
445
|
+
The `|2` indent indicator is written **explicitly**: with a bare `|` YAML infers
|
|
446
|
+
the indent from the first non-empty line, and a body whose first line is itself
|
|
447
|
+
indented (a code block, a nested list) parses as an error.
|
|
448
|
+
"""
|
|
449
|
+
import yaml
|
|
450
|
+
|
|
451
|
+
header = {
|
|
452
|
+
"version": "1.0.0",
|
|
453
|
+
"description": description,
|
|
454
|
+
"parameters": [
|
|
455
|
+
{
|
|
456
|
+
"key": "args",
|
|
457
|
+
"input_type": "string",
|
|
458
|
+
"requirement": "optional",
|
|
459
|
+
"default": "",
|
|
460
|
+
"description": "User input passed to the command.",
|
|
461
|
+
}
|
|
462
|
+
],
|
|
463
|
+
}
|
|
464
|
+
header_text = yaml.safe_dump(header, sort_keys=False, allow_unicode=True).rstrip("\n")
|
|
465
|
+
indented = "\n".join((" " + line) if line else "" for line in body.split("\n"))
|
|
466
|
+
return f"{header_text}\nprompt: |2\n{indented}\n\n# Source: {source_name}\n"
|
|
467
|
+
|
|
468
|
+
def setup(
|
|
469
|
+
self, project_root: Path, commands_dir: Path, *, force: bool = False
|
|
470
|
+
) -> tuple[list[Path], list[Path]]:
|
|
471
|
+
dest_dir = self.commands_dest(project_root)
|
|
472
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
473
|
+
written: list[Path] = []
|
|
474
|
+
skipped: list[Path] = []
|
|
475
|
+
placeholder = self.arg_placeholder()
|
|
476
|
+
for src in self.list_command_templates(commands_dir):
|
|
477
|
+
dest = dest_dir / self.command_filename(src.name)
|
|
478
|
+
if dest.exists() and not force:
|
|
479
|
+
skipped.append(dest)
|
|
480
|
+
continue
|
|
481
|
+
raw = src.read_text(encoding="utf-8")
|
|
482
|
+
frontmatter, _ = self.split_frontmatter(raw)
|
|
483
|
+
description = TomlIntegration.extract_description(frontmatter)
|
|
484
|
+
processed = self.process_template(
|
|
485
|
+
raw, self.key, arg_placeholder=placeholder, invoke_separator=self.invoke_separator
|
|
486
|
+
)
|
|
487
|
+
_, body = self.split_frontmatter(processed)
|
|
488
|
+
written.append(
|
|
489
|
+
self.write_file(self.render_yaml(description, body, src.name), dest)
|
|
490
|
+
)
|
|
491
|
+
return written, skipped
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class SkillsIntegration(IntegrationBase):
|
|
495
|
+
"""`<dir>/<skill-name>/SKILL.md` — one directory per command.
|
|
496
|
+
|
|
497
|
+
Declares `invoke_separator = "-"`: under a skills layout the invocation name is the
|
|
498
|
+
**directory name**, so `/specpro.specify` becomes `/specpro-specify`.
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
invoke_separator: str = "-"
|
|
502
|
+
|
|
503
|
+
def skills_dest(self, project_root: Path) -> Path:
|
|
504
|
+
config = self._require_config()
|
|
505
|
+
subdir = config.get("commands_subdir") or "skills"
|
|
506
|
+
return _resource_targets(project_root, config["folder"], subdir)
|
|
507
|
+
|
|
508
|
+
def command_filename(self, template_name: str) -> str:
|
|
509
|
+
return f"specpro-{command_stem(template_name).replace('.', '-')}/SKILL.md"
|
|
510
|
+
|
|
511
|
+
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
|
512
|
+
stem = command_name
|
|
513
|
+
if stem.startswith("specpro."):
|
|
514
|
+
stem = stem[len("specpro.") :]
|
|
515
|
+
invocation = "/specpro-" + stem.replace(".", "-")
|
|
516
|
+
return f"{invocation} {args}".strip()
|
|
517
|
+
|
|
518
|
+
def skill_frontmatter_extra(
|
|
519
|
+
self, stem: str, description: str, source_name: str
|
|
520
|
+
) -> list[str]:
|
|
521
|
+
"""Extra frontmatter lines this agent wants on every skill. Default: none.
|
|
522
|
+
|
|
523
|
+
This hook exists because the contract names it: `agent-registry.md` →
|
|
524
|
+
「每个集成模块必须声明」→ **子类方法覆写** = "agent 特有的内容加工(如
|
|
525
|
+
argument-hints)", and the agent that needs it is `claude`.
|
|
526
|
+
|
|
527
|
+
The split matters: the *common* part of the header stays in
|
|
528
|
+
`build_skill_frontmatter`, and an agent that needs more overrides **this** — so
|
|
529
|
+
adding an agent never means editing the shared builder. That is the same reason
|
|
530
|
+
the five format bases exist, one level down.
|
|
531
|
+
"""
|
|
532
|
+
return []
|
|
533
|
+
|
|
534
|
+
@staticmethod
|
|
535
|
+
def build_skill_frontmatter(
|
|
536
|
+
stem: str,
|
|
537
|
+
description: str,
|
|
538
|
+
source_name: str,
|
|
539
|
+
extra_lines: Sequence[str] = (),
|
|
540
|
+
) -> str:
|
|
541
|
+
"""The five-field skill header, plus whatever the agent adds.
|
|
542
|
+
|
|
543
|
+
Every value is double-quoted: an unquoted scalar cannot carry the line breaks,
|
|
544
|
+
leading `>`/`|`, or control characters that a description may contain.
|
|
545
|
+
|
|
546
|
+
`extra_lines` is appended **after** the `metadata` block and **before** the
|
|
547
|
+
closing `---`: appending after a block scalar keeps the five shared fields
|
|
548
|
+
contiguous, and the extras are top-level keys, which is where the agent's own
|
|
549
|
+
field belongs.
|
|
550
|
+
"""
|
|
551
|
+
import json
|
|
552
|
+
|
|
553
|
+
def quote(value: str) -> str:
|
|
554
|
+
return json.dumps(value, ensure_ascii=False)
|
|
555
|
+
|
|
556
|
+
lines = [
|
|
557
|
+
"---",
|
|
558
|
+
f"name: {quote('specpro-' + stem)}",
|
|
559
|
+
f"description: {quote(description or f'specpro: {stem} workflow')}",
|
|
560
|
+
f"compatibility: {quote('Requires a specpro project structure with .specpro/ directory')}",
|
|
561
|
+
"metadata:",
|
|
562
|
+
" author: specpro",
|
|
563
|
+
f" source: {quote(source_name)}",
|
|
564
|
+
*extra_lines,
|
|
565
|
+
"---",
|
|
566
|
+
"",
|
|
567
|
+
]
|
|
568
|
+
return "\n".join(lines)
|
|
569
|
+
|
|
570
|
+
def setup(
|
|
571
|
+
self, project_root: Path, commands_dir: Path, *, force: bool = False
|
|
572
|
+
) -> tuple[list[Path], list[Path]]:
|
|
573
|
+
dest_dir = self.skills_dest(project_root)
|
|
574
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
575
|
+
written: list[Path] = []
|
|
576
|
+
skipped: list[Path] = []
|
|
577
|
+
placeholder = self.arg_placeholder()
|
|
578
|
+
for src in self.list_command_templates(commands_dir):
|
|
579
|
+
dest = dest_dir / self.command_filename(src.name)
|
|
580
|
+
if dest.exists() and not force:
|
|
581
|
+
skipped.append(dest)
|
|
582
|
+
continue
|
|
583
|
+
raw = src.read_text(encoding="utf-8")
|
|
584
|
+
frontmatter, _ = self.split_frontmatter(raw)
|
|
585
|
+
description = TomlIntegration.extract_description(frontmatter)
|
|
586
|
+
processed = self.process_template(
|
|
587
|
+
raw, self.key, arg_placeholder=placeholder, invoke_separator=self.invoke_separator
|
|
588
|
+
)
|
|
589
|
+
_, body = self.split_frontmatter(processed)
|
|
590
|
+
content = self.build_skill_frontmatter(
|
|
591
|
+
command_stem(src.name),
|
|
592
|
+
description,
|
|
593
|
+
src.name,
|
|
594
|
+
self.skill_frontmatter_extra(command_stem(src.name), description, src.name),
|
|
595
|
+
) + body
|
|
596
|
+
written.append(self.write_file(content, dest))
|
|
597
|
+
return written, skipped
|
|
598
|
+
|
|
599
|
+
@staticmethod
|
|
600
|
+
def split_frontmatter(content: str) -> tuple[str, str]:
|
|
601
|
+
return TomlIntegration.split_frontmatter(content)
|