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.
Files changed (76) hide show
  1. specpro_cli/__init__.py +16 -0
  2. specpro_cli/assets/commands/specpro.analyze.md +1102 -0
  3. specpro_cli/assets/commands/specpro.checklist.md +335 -0
  4. specpro_cli/assets/commands/specpro.clarify.md +581 -0
  5. specpro_cli/assets/commands/specpro.constitution.md +488 -0
  6. specpro_cli/assets/commands/specpro.feature.md +115 -0
  7. specpro_cli/assets/commands/specpro.implement.md +1881 -0
  8. specpro_cli/assets/commands/specpro.manual-test.md +206 -0
  9. specpro_cli/assets/commands/specpro.plan.md +3284 -0
  10. specpro_cli/assets/commands/specpro.qc.md +1489 -0
  11. specpro_cli/assets/commands/specpro.scenarios.md +154 -0
  12. specpro_cli/assets/commands/specpro.specify.md +1449 -0
  13. specpro_cli/assets/commands/specpro.status.md +863 -0
  14. specpro_cli/assets/commands/specpro.tasks.md +1207 -0
  15. specpro_cli/assets/commands/specpro.test-implement.md +462 -0
  16. specpro_cli/assets/commands/specpro.test-plan.md +383 -0
  17. specpro_cli/assets/commands/specpro.user-manual.md +178 -0
  18. specpro_cli/assets/scripts/bash/check-anti-coupling.sh +293 -0
  19. specpro_cli/assets/scripts/bash/check-prerequisites.sh +176 -0
  20. specpro_cli/assets/scripts/bash/common.sh +88 -0
  21. specpro_cli/assets/scripts/bash/create-new-feature.sh +336 -0
  22. specpro_cli/assets/scripts/bash/qc-auto-fix.sh +121 -0
  23. specpro_cli/assets/scripts/bash/setup-plan.sh +60 -0
  24. specpro_cli/assets/scripts/bash/verify-cumulative-records.sh +203 -0
  25. specpro_cli/assets/scripts/bash/verify-deliverables-tracked.sh +147 -0
  26. specpro_cli/assets/scripts/bash/verify-deployment.sh +239 -0
  27. specpro_cli/assets/scripts/bash/verify-frontmatter-yaml.sh +63 -0
  28. specpro_cli/assets/scripts/bash/verify-ledger.sh +376 -0
  29. specpro_cli/assets/scripts/bash/verify-shapes.sh +1082 -0
  30. specpro_cli/assets/scripts/git-hooks/pre-commit +243 -0
  31. specpro_cli/assets/scripts/install-git-hooks.sh +67 -0
  32. specpro_cli/assets/scripts/powershell/check-anti-coupling.ps1 +249 -0
  33. specpro_cli/assets/scripts/powershell/check-prerequisites.ps1 +148 -0
  34. specpro_cli/assets/scripts/powershell/common.ps1 +95 -0
  35. specpro_cli/assets/scripts/powershell/create-new-feature.ps1 +229 -0
  36. specpro_cli/assets/scripts/powershell/qc-auto-fix.ps1 +110 -0
  37. specpro_cli/assets/scripts/powershell/setup-plan.ps1 +61 -0
  38. specpro_cli/assets/scripts/powershell/verify-cumulative-records.ps1 +133 -0
  39. specpro_cli/assets/scripts/powershell/verify-deliverables-tracked.ps1 +112 -0
  40. specpro_cli/assets/scripts/powershell/verify-deployment.ps1 +278 -0
  41. specpro_cli/assets/scripts/powershell/verify-frontmatter-yaml.ps1 +56 -0
  42. specpro_cli/assets/scripts/powershell/verify-ledger.ps1 +383 -0
  43. specpro_cli/assets/scripts/powershell/verify-shapes.ps1 +978 -0
  44. specpro_cli/assets/templates/agent-context-template.md +49 -0
  45. specpro_cli/assets/templates/assumptions-template.md +248 -0
  46. specpro_cli/assets/templates/checklist-template.md +40 -0
  47. specpro_cli/assets/templates/clarifications-template.md +155 -0
  48. specpro_cli/assets/templates/constitution-template.md +50 -0
  49. specpro_cli/assets/templates/feature-spec-template.md +66 -0
  50. specpro_cli/assets/templates/plan-overview-template.md +150 -0
  51. specpro_cli/assets/templates/plan-template.md +387 -0
  52. specpro_cli/assets/templates/protocol-golden-bytes-guide.md +195 -0
  53. specpro_cli/assets/templates/requirements-template.md +356 -0
  54. specpro_cli/assets/templates/spec-template.md +267 -0
  55. specpro_cli/assets/templates/tasks-template.md +252 -0
  56. specpro_cli/assets/templates/test-tasks-template.md +174 -0
  57. specpro_cli/cli/__init__.py +5 -0
  58. specpro_cli/cli/cmd_init.py +416 -0
  59. specpro_cli/cli/cmd_remove.py +122 -0
  60. specpro_cli/cli/entry.py +181 -0
  61. specpro_cli/integrations/__init__.py +36 -0
  62. specpro_cli/integrations/base.py +601 -0
  63. specpro_cli/integrations/claude/__init__.py +101 -0
  64. specpro_cli/integrations/copilot/__init__.py +153 -0
  65. specpro_cli/integrations/cursor_agent/__init__.py +51 -0
  66. specpro_cli/integrations/gemini/__init__.py +44 -0
  67. specpro_cli/integrations/opencode/__init__.py +48 -0
  68. specpro_cli/integrations/qodercli/__init__.py +54 -0
  69. specpro_cli/integrations/registry.py +88 -0
  70. specpro_cli/packaged/__init__.py +5 -0
  71. specpro_cli/packaged/sync.py +106 -0
  72. specpro_cli-0.1.0.dist-info/METADATA +117 -0
  73. specpro_cli-0.1.0.dist-info/RECORD +76 -0
  74. specpro_cli-0.1.0.dist-info/WHEEL +4 -0
  75. specpro_cli-0.1.0.dist-info/entry_points.txt +2 -0
  76. 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)