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,101 @@
1
+ """Claude Code — skills layout under `.claude/skills/`.
2
+
3
+ **Why the skills layout and not `.claude/commands/`** ⚠️: this was decided twice. The
4
+ 2026-09-16 ruling kept the flat commands layout to avoid redoing the bootstrap mirror;
5
+ the 2026-09-17 ruling reversed it and aligned with spec-kit. The reversal is recorded in
6
+ `agent-registry.md` → 「`claude` 的落位对齐 spec-kit」, together with the fact that it
7
+ **accepts the migration cost** — so the note here is a pointer, not a second decision.
8
+
9
+ The consequence a reader needs: the invocation name is the **skill directory name**, so
10
+ `specpro.specify` is invoked as `/specpro-specify`. `SkillsIntegration` already carries
11
+ that (`invoke_separator = "-"` plus the dot-to-hyphen passes in `command_filename` and
12
+ `build_command_invocation`), so this module declares only what is claude's own.
13
+
14
+ Source: L2 spec-kit 1.0.8.dev0 (vendored `de2a5ff`)
15
+ [`src/specify_cli/integrations/claude/__init__.py:38` (`class ClaudeIntegration(SkillsIntegration)`)
16
+ · `:43` (`config`) · `:49` (`registrar_config`)]
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+
23
+ from specpro_cli.integrations import registry
24
+ from specpro_cli.integrations.base import SkillsIntegration
25
+
26
+ # ---------------------------------------------------------------------------------------
27
+ # argument-hint per command
28
+ # ---------------------------------------------------------------------------------------
29
+ #
30
+ # Claude Code renders `argument-hint` next to the slash command as a usage prompt. The
31
+ # values below mirror what each command's own documentation already says it accepts —
32
+ # `CLAUDE.md` → 「常用命令」 and the command documents' own argument lists. Keeping them
33
+ # once, here, is what makes this a *declaration* rather than a second specification: the
34
+ # command documents stay the source for what an argument means, and this table owns only
35
+ # how it is displayed.
36
+ #
37
+ # ⚠️ A command that takes nothing is **absent** rather than mapped to an empty string, and
38
+ # `_hint_for` returns no line for it. An emitted `argument-hint: ""` would render as an
39
+ # empty prompt, which reads as "usage information is missing" instead of "this command
40
+ # takes no arguments" — the two look identical to the user and only one is true.
41
+ _ARGUMENT_HINTS: dict[str, str] = {
42
+ "constitution": "[project description, or --review-issues]",
43
+ "specify": "<feature description>",
44
+ "clarify": "[--scan]",
45
+ "plan": "[--review-issues | --continue]",
46
+ "tasks": "[--review-issues]",
47
+ "implement": "[--once | --count N | --phase <phase> | --review-issues | --fix-defects]",
48
+ "test-plan": "[--review-issues]",
49
+ "test-implement": "[--layer <infra|integration|ce2e|ae2e>]",
50
+ "qc": "[--scope <full|incremental>] [--auto]",
51
+ "checklist": "[domain]",
52
+ "analyze": "[--persist | --summary | --incremental]",
53
+ }
54
+
55
+
56
+ def _hint_for(stem: str) -> str | None:
57
+ """The hint for `stem`, or `None` when the command takes no arguments."""
58
+ return _ARGUMENT_HINTS.get(stem)
59
+
60
+
61
+ class ClaudeIntegration(SkillsIntegration):
62
+ """Claude Code: one skill directory per command, under `.claude/skills/`."""
63
+
64
+ key = "claude"
65
+ cli_command = "claude" # FR-068 — the agent's own CLI, probed by `specpro check`
66
+
67
+ config = {
68
+ "name": "Claude Code",
69
+ "folder": ".claude/",
70
+ "commands_subdir": "skills",
71
+ }
72
+
73
+ registrar_config = {
74
+ "dir": ".claude/skills",
75
+ "format": "markdown",
76
+ "extension": "/SKILL.md",
77
+ }
78
+
79
+ # Declared in `agent-registry.md` → 「每个集成模块必须声明」→ `multi_install_safe`
80
+ # ("声明可与他人共存于同一项目", claude · cursor-agent · qodercli). It is a claim
81
+ # about the *layout*, not a feature: each agent writes under its own dot-directory, so
82
+ # two agents installed into one project cannot collide.
83
+ multi_install_safe = True
84
+
85
+ def skill_frontmatter_extra(
86
+ self, stem: str, description: str, source_name: str
87
+ ) -> list[str]:
88
+ """Inject `argument-hint` — the agent-specific processing this subclass exists for.
89
+
90
+ This is T020's override: `agent-registry.md` lists claude as the agent carrying
91
+ 「子类方法覆写」, and the field it injects is `argument-hint`.
92
+ """
93
+ hint = _hint_for(stem)
94
+ if hint is None:
95
+ return []
96
+ # Quoted via `json.dumps`, the same way `build_skill_frontmatter` quotes its own
97
+ # values — a hint containing `>` or `:` would otherwise be re-read as YAML.
98
+ return [f"argument-hint: {json.dumps(hint, ensure_ascii=False)}"]
99
+
100
+
101
+ registry.register(ClaudeIntegration())
@@ -0,0 +1,153 @@
1
+ """GitHub Copilot — **two** locations: the agent file, plus a companion prompt file.
2
+
3
+ Declared in `specs/contracts/agent-registry.md` -> 「首批 agent」:
4
+
5
+ `copilot` | GitHub Copilot | Base 直接子类 | **主**:`.github/agents/`(`.agent.md`)
6
+ | **伴生**:`.github/prompts/`(`.prompt.md`)
7
+
8
+ ⚠️ **The 主落位 is `.github/agents/`, NOT `.github/prompts/`** — the contract carries an
9
+ explicit correction on this point: an earlier revision recorded the companion directory
10
+ as the main one, and that error is exactly the shape this repository keeps meeting (a
11
+ value stated in two places, one of them wrong, and nothing compares them).
12
+
13
+ ⚠️ **Why this is an `IntegrationBase` DIRECT subclass rather than one of the five format
14
+ bases**: the five bases each describe *one* output shape. This agent's deliverable is
15
+ *two* files per command, and the second one is not a re-rendering of the first — it is a
16
+ companion that points at it. `plan.md` -> the format table records this as the 「需额外伴生
17
+ 文件的特例」, and the base-class docstring is explicit that "required" is enforced at
18
+ runtime rather than by `@abstractmethod` — so what this module MUST do is declare the
19
+ constants and override `setup`, and the registry's registration check is what catches a
20
+ half-declared subclass.
21
+
22
+ ⚠️ **What was NOT done, stated honestly**: the companion's *content* below is the minimum
23
+ that makes it a prompt file — a frontmatter naming the agent it invokes, plus a body that
24
+ delegates to it. The registry contract's Source column lists the reference
25
+ implementation's field set (`description` / `name` / `argument-hint` / `agent` / `model` /
26
+ `tools`) but does **not** state which of those this project must emit or what each means
27
+ for this agent, and inventing the other three from the field *names* would be an
28
+ unmeasured claim about a peer's behaviour — the same class of assertion the contract's
29
+ own 「未经测量的对端行为断言」 note corrects elsewhere. ⇒ **Emitted: `description`,
30
+ `name`, `agent`.** The rest are left out rather than guessed.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from pathlib import Path
36
+
37
+ from specpro_cli.integrations import registry
38
+ from specpro_cli.integrations.base import IntegrationBase, command_stem
39
+
40
+
41
+ class CopilotIntegration(IntegrationBase):
42
+ """GitHub Copilot: an agent file under `.github/agents/`, plus its companion prompt."""
43
+
44
+ key = "copilot"
45
+ cli_command = "copilot" # FR-068 — the agent's own CLI, probed by `specpro check`
46
+
47
+ config = {
48
+ "name": "GitHub Copilot",
49
+ "folder": ".github/",
50
+ "commands_subdir": "agents",
51
+ }
52
+
53
+ registrar_config = {
54
+ "dir": ".github/agents",
55
+ "format": "markdown",
56
+ "extension": ".agent.md",
57
+ }
58
+
59
+ # --- the companion location --------------------------------------------------------
60
+ #
61
+ # Declared as a constant rather than inlined at its use site: the entry layer reads
62
+ # `registrar_config` to compute 落位 and MUST NOT hardcode a path, so the second
63
+ # location belongs beside the first rather than inside `setup`'s body.
64
+ companion_subdir: str = "prompts"
65
+ companion_extension: str = ".prompt.md"
66
+
67
+ def companion_dest(self, project_root: Path) -> Path:
68
+ """Where this integration's companion prompt files land."""
69
+ config = self._require_config()
70
+ return project_root / config["folder"] / self.companion_subdir
71
+
72
+ def companion_filename(self, template_name: str) -> str:
73
+ return f"specpro.{command_stem(template_name)}{self.companion_extension}"
74
+
75
+ def command_filename(self, template_name: str) -> str:
76
+ # Mirrors `registrar_config["extension"]`; a base-class default would produce
77
+ # `specpro.<name>.md`, which this agent does not read.
78
+ return f"specpro.{command_stem(template_name)}.agent.md"
79
+
80
+ def planned_paths(self, project_root: Path, commands_dir: Path) -> list[Path]:
81
+ """Both files per command — the agent file AND its companion prompt.
82
+
83
+ The uninstall contract removes "exactly what `setup()` would write"; for this
84
+ agent that is twice the template count, and a default that listed only the
85
+ agent files would leave every companion behind as an orphan pointing at an
86
+ uninstalled agent.
87
+ """
88
+ dest_dir = self.commands_dest(project_root)
89
+ companion_dir = self.companion_dest(project_root)
90
+ return [
91
+ path
92
+ for src in self.list_command_templates(commands_dir)
93
+ for path in (
94
+ dest_dir / self.command_filename(src.name),
95
+ companion_dir / self.companion_filename(src.name),
96
+ )
97
+ ]
98
+
99
+ def deploy_roots(self, project_root: Path) -> list[Path]:
100
+ return [self.commands_dest(project_root), self.companion_dest(project_root)]
101
+
102
+ def setup(
103
+ self, project_root: Path, commands_dir: Path, *, force: bool = False
104
+ ) -> tuple[list[Path], list[Path]]:
105
+ """Deploy BOTH files per command, and return everything written or skipped.
106
+
107
+ ⚠️ **The return value is the report's only input** (`install-cli.md` -> Output
108
+ Contract): a companion written but not returned would be a file the user is never
109
+ told about — and "installed" would then be a claim about a subset nobody named.
110
+
111
+ The conflict gate judges the PAIR by the agent file: if it exists and no
112
+ `--force` was given, both halves are reported skipped together. The two files
113
+ are one command's deliverable — an agent file kept (possibly user-edited) under
114
+ a freshly overwritten companion would deploy a half-old half-new command while
115
+ the report counted only one skip, so the pair moves as one.
116
+ """
117
+ dest_dir = self.commands_dest(project_root)
118
+ dest_dir.mkdir(parents=True, exist_ok=True)
119
+ companion_dir = self.companion_dest(project_root)
120
+ companion_dir.mkdir(parents=True, exist_ok=True)
121
+
122
+ written: list[Path] = []
123
+ skipped: list[Path] = []
124
+ for src in self.list_command_templates(commands_dir):
125
+ stem = command_stem(src.name)
126
+ agent_path = dest_dir / self.command_filename(src.name)
127
+ companion_path = companion_dir / self.companion_filename(src.name)
128
+ if agent_path.exists() and not force:
129
+ skipped.append(agent_path)
130
+ skipped.append(companion_path)
131
+ continue
132
+ self.write_file(src.read_text(encoding="utf-8"), agent_path)
133
+ written.append(agent_path)
134
+ written.append(
135
+ self.write_file(self._render_companion(stem), companion_path)
136
+ )
137
+ return written, skipped
138
+
139
+ @staticmethod
140
+ def _render_companion(stem: str) -> str:
141
+ """The companion prompt: frontmatter naming the agent, then a delegation body."""
142
+ return (
143
+ "---\n"
144
+ f"name: specpro-{stem}\n"
145
+ f"description: Run the specpro `{stem}` command as a Copilot agent.\n"
146
+ f"agent: specpro-{stem}\n"
147
+ "---\n"
148
+ "\n"
149
+ f"Invoke the `specpro-{stem}` agent with the arguments below.\n"
150
+ )
151
+
152
+
153
+ registry.register(CopilotIntegration())
@@ -0,0 +1,51 @@
1
+ """Cursor — one skill directory per command, under `.cursor/skills/`.
2
+
3
+ Declared in `specs/contracts/agent-registry.md` -> 「首批 agent」: `cursor-agent` is a
4
+ **Skills** agent with 落位 `.cursor/skills/` (source: the reference implementation's
5
+ `cursor_agent/__init__.py:21,35`, `dir=".cursor/skills"`).
6
+
7
+ ⚠️ **This module declares constants and overrides nothing** — that is the `IntegrationBase`
8
+ contract ("a new agent declares four groups of constants and overrides nothing else").
9
+ Anything the base already does right MUST NOT be restated here: a second copy of the
10
+ invocation-name rule (`SkillsIntegration` already turns `/specpro.specify` into
11
+ `/specpro-specify`) would drift from the base silently, and the drift would be invisible
12
+ because both copies would keep looking authoritative.
13
+
14
+ ⚠️ **Key vs module name**: `key` is the user-facing `--agent` value and matches the
15
+ agent's own binary name (hyphenated); the Python module uses an underscore because that
16
+ is what an import accepts. The registry contract states both, and they are not required
17
+ to agree.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from specpro_cli.integrations import registry
23
+ from specpro_cli.integrations.base import SkillsIntegration
24
+
25
+
26
+ class CursorAgentIntegration(SkillsIntegration):
27
+ """Cursor: one skill directory per command, under `.cursor/skills/`."""
28
+
29
+ key = "cursor-agent"
30
+ cli_command = "cursor-agent" # FR-068 — the agent's own CLI, probed by `specpro check`
31
+
32
+ config = {
33
+ "name": "Cursor",
34
+ "folder": ".cursor/",
35
+ "commands_subdir": "skills",
36
+ }
37
+
38
+ registrar_config = {
39
+ "dir": ".cursor/skills",
40
+ "format": "markdown",
41
+ "extension": "/SKILL.md",
42
+ }
43
+
44
+ # `agent-registry.md` -> 「可选声明」-> `multi_install_safe`: "声明可与他人共存于同一
45
+ # 项目", and the contract lists cursor-agent among the three that declare it. It is a
46
+ # claim about the *layout*, not a feature: each agent writes under its own
47
+ # dot-directory, so two agents installed into one project cannot collide.
48
+ multi_install_safe = True
49
+
50
+
51
+ registry.register(CursorAgentIntegration())
@@ -0,0 +1,44 @@
1
+ """Gemini CLI — one TOML file per command, under `.gemini/commands/`.
2
+
3
+ Declared in `specs/contracts/agent-registry.md` -> 「首批 agent」: `gemini` is a **Toml**
4
+ agent with 落位 `.gemini/commands/` (source: the reference implementation's
5
+ `gemini/__init__.py:6,15`, `dir=".gemini/commands"` · `extension=".toml"`).
6
+
7
+ ⚠️ **This module declares constants and overrides nothing** — same rule as every other
8
+ agent module: `TomlIntegration` already owns the `.toml` filename, the `{{args}}`
9
+ placeholder and the frontmatter→`prompt` repackaging, so a subclass that restated any of
10
+ them would be a second source for a fact the base already answers.
11
+
12
+ ⚠️ **Why this agent is not `MarkdownIntegration`**: the delivered file is not Markdown
13
+ with a header — it is TOML whose `prompt` key holds the Markdown body as a scalar. That
14
+ transformation is the reason the format base classes exist (`plan.md` -> the five-format
15
+ table), and picking the wrong one would produce a file that *looks* deployed and is not
16
+ readable by the agent.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from specpro_cli.integrations import registry
22
+ from specpro_cli.integrations.base import TomlIntegration
23
+
24
+
25
+ class GeminiIntegration(TomlIntegration):
26
+ """Gemini CLI: one TOML file per command, under `.gemini/commands/`."""
27
+
28
+ key = "gemini"
29
+ cli_command = "gemini" # FR-068 — the agent's own CLI, probed by `specpro check`
30
+
31
+ config = {
32
+ "name": "Gemini CLI",
33
+ "folder": ".gemini/",
34
+ "commands_subdir": "commands",
35
+ }
36
+
37
+ registrar_config = {
38
+ "dir": ".gemini/commands",
39
+ "format": "toml",
40
+ "extension": ".toml",
41
+ }
42
+
43
+
44
+ registry.register(GeminiIntegration())
@@ -0,0 +1,48 @@
1
+ """opencode — one Markdown file per command, under `.opencode/commands/`.
2
+
3
+ Declared in `specs/contracts/agent-registry.md` -> 「首批 agent」: `opencode` is a
4
+ **Markdown** agent with 落位 `.opencode/commands/`.
5
+
6
+ ⚠️ **This module declares constants and overrides nothing** — same rule as every other
7
+ agent module: `MarkdownIntegration` already owns the target filename (the source name
8
+ unchanged — specpro's templates carry the `specpro.` prefix), the `$ARGUMENTS`
9
+ placeholder and the frontmatter/body substitution, so a subclass that restated any of
10
+ them would be a second source for a fact the base already answers.
11
+
12
+ The reference implementation also records an older flat dir for this agent
13
+ (`.opencode/command`, singular); the contract's optional-field table lists the
14
+ `legacy_flat_command_*` pair as used by `qodercli` alone in the first batch, so it is
15
+ deliberately not declared here — adding it without a consumer would be machinery
16
+ nobody reads.
17
+
18
+ Source: L2 spec-kit 1.0.8.dev0 (vendored `de2a5ff`)
19
+ [`src/specify_cli/integrations/opencode/__init__.py:9,18` (`dir=".opencode/commands"`
20
+ · `legacy_dir=".opencode/command"`)]
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from specpro_cli.integrations import registry
26
+ from specpro_cli.integrations.base import MarkdownIntegration
27
+
28
+
29
+ class OpencodeIntegration(MarkdownIntegration):
30
+ """opencode: one Markdown file per command, under `.opencode/commands/`."""
31
+
32
+ key = "opencode"
33
+ cli_command = "opencode" # FR-068 — the agent's own CLI, probed by `specpro check`
34
+
35
+ config = {
36
+ "name": "opencode",
37
+ "folder": ".opencode/",
38
+ "commands_subdir": "commands",
39
+ }
40
+
41
+ registrar_config = {
42
+ "dir": ".opencode/commands",
43
+ "format": "markdown",
44
+ "extension": ".md",
45
+ }
46
+
47
+
48
+ registry.register(OpencodeIntegration())
@@ -0,0 +1,54 @@
1
+ """Qoder CLI — skills layout under `.qoder/skills/`.
2
+
3
+ Declared in `specs/contracts/agent-registry.md` -> 「首批 agent」: `qodercli` is a
4
+ **Skills** agent with 落位 `.qoder/skills/`, plus the first-batch's only legacy
5
+ declaration — versions before 1.24 used the flat `.qoder/commands/` layout, recorded
6
+ via `legacy_flat_command_dir` / `legacy_flat_command_extension` for migration and
7
+ cleanup (the fields' consumers live with the entry layer's migration path, not here:
8
+ this module only declares the facts).
9
+
10
+ `multi_install_safe = True` follows the contract's optional-field table (claude ·
11
+ cursor-agent · qodercli): each writes under its own dot-directory, so two agents
12
+ installed into one project cannot collide. Like claude's, it is a claim about the
13
+ *layout*, not a feature.
14
+
15
+ The invocation name is the skill directory name (`/specpro-<stem>`); `SkillsIntegration`
16
+ already carries that (`invoke_separator = "-"` plus the dot-to-hyphen passes), so this
17
+ module declares only what is qodercli's own.
18
+
19
+ Source: L2 spec-kit 1.0.8.dev0 (vendored `de2a5ff`)
20
+ [`src/specify_cli/integrations/qodercli/__init__.py:11,20,26` (`dir=".qoder/skills"`
21
+ · `legacy_flat_command_dir=".qoder/commands"` · `legacy_flat_command_extension=".md"`)]
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from specpro_cli.integrations import registry
27
+ from specpro_cli.integrations.base import SkillsIntegration
28
+
29
+
30
+ class QodercliIntegration(SkillsIntegration):
31
+ """Qoder CLI: one skill directory per command, under `.qoder/skills/`."""
32
+
33
+ key = "qodercli"
34
+ cli_command = "qoder" # FR-068 — the agent's own CLI, probed by `specpro check`
35
+
36
+ config = {
37
+ "name": "Qoder CLI",
38
+ "folder": ".qoder/",
39
+ "commands_subdir": "skills",
40
+ }
41
+
42
+ registrar_config = {
43
+ "dir": ".qoder/skills",
44
+ "format": "markdown",
45
+ "extension": "/SKILL.md",
46
+ }
47
+
48
+ legacy_flat_command_dir = ".qoder/commands"
49
+ legacy_flat_command_extension = ".md"
50
+
51
+ multi_install_safe = True
52
+
53
+
54
+ registry.register(QodercliIntegration())
@@ -0,0 +1,88 @@
1
+ """The integration registry — the install system's single source of truth.
2
+
3
+ `specs/contracts/agent-registry.md` -> `## Registry` defines this module's job:
4
+ a module-level `key -> integration instance` map, each integration registering itself
5
+ on import, with the entry layer deriving everything (candidate enumeration, `--agent`
6
+ validation, invocation resolution) from this map rather than keeping a second list.
7
+
8
+ **Why a registry and not a config file**: the five format base classes already carry
9
+ the common part of "how to transform"; what remains is per-agent processing that only
10
+ code can express (claude's argument hints are the first case). Forcing both into a data
11
+ file yields a data file with an escape hatch — the shape the v1.0 design was rejected
12
+ for.
13
+
14
+ ⚠️ **No manifest**: spec-kit's `setup(project_root, manifest, ...)` tracks installed
15
+ files in a manifest object. specpro's contract declares no such artifact, and the
16
+ information it would hold — what is installed where — is derivable from the registry
17
+ plus the `--agent` key. Adding it would be a design change, not an implementation one.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import TYPE_CHECKING
23
+
24
+ if TYPE_CHECKING: # pragma: no cover
25
+ from specpro_cli.integrations.base import IntegrationBase
26
+
27
+ __all__ = [
28
+ "clear",
29
+ "get",
30
+ "keys",
31
+ "register",
32
+ "registered",
33
+ ]
34
+
35
+ # The single source of truth. Consumers read this; nothing else keeps a key list.
36
+ _REGISTRY: dict[str, IntegrationBase] = {}
37
+
38
+
39
+ def register(integration: IntegrationBase) -> IntegrationBase:
40
+ """Register `integration` under its `key`.
41
+
42
+ Two failures are refused rather than absorbed, because both would otherwise be
43
+ silent: an **empty key** produces an entry no `--agent` value can ever select, and
44
+ a **duplicate key** means the second registration overwrites the first — after
45
+ which which integration answers for that key depends on import order, and nothing
46
+ reports it.
47
+ """
48
+ key = getattr(integration, "key", "")
49
+ if not key:
50
+ raise ValueError(
51
+ f"{type(integration).__name__}: `key` MUST be a non-empty string "
52
+ "(an empty key names no `--agent` value and can never be selected)"
53
+ )
54
+ if key in _REGISTRY:
55
+ existing = type(_REGISTRY[key]).__name__
56
+ raise ValueError(
57
+ f"duplicate integration key {key!r}: already registered by {existing}, "
58
+ f"now {type(integration).__name__} — duplicate registration MUST fail "
59
+ "rather than silently overwrite"
60
+ )
61
+ _REGISTRY[key] = integration
62
+ return integration
63
+
64
+
65
+ def registered() -> dict[str, IntegrationBase]:
66
+ """The whole map. Returns a copy — callers must not mutate the source of truth."""
67
+ return dict(_REGISTRY)
68
+
69
+
70
+ def keys() -> list[str]:
71
+ """Every registered key, sorted. The value set of `--agent`."""
72
+ return sorted(_REGISTRY)
73
+
74
+
75
+ def get(key: str) -> IntegrationBase | None:
76
+ """The integration for `key`, or `None`.
77
+
78
+ Returning `None` rather than raising is deliberate: the caller's job is to reject
79
+ an unknown `--agent` value with a message that lists the valid ones (FR-005), and a
80
+ lookup that raised would make "not found" and "lookup itself failed"
81
+ indistinguishable at the call site.
82
+ """
83
+ return _REGISTRY.get(key)
84
+
85
+
86
+ def clear() -> None:
87
+ """Empty the registry. For tests only — there is no production caller."""
88
+ _REGISTRY.clear()
@@ -0,0 +1,5 @@
1
+ """Packaging manifest — which assets enter the wheel.
2
+
3
+ The snapshot under `assets/` is synced from the repository root at build time and
4
+ MUST NOT be hand-maintained (Constraint 4).
5
+ """
@@ -0,0 +1,106 @@
1
+ """The asset snapshot — synced from the repository root, never hand-maintained.
2
+
3
+ Constraint 4: the assets inside the wheel MUST match the repository root, and MUST be
4
+ produced by the build rather than edited. `assets/` is `.gitignore`d, so nothing here
5
+ is ever committed; the wheel gets it through `force-include`, which (unlike the
6
+ `packages` channel) does not respect `.gitignore`.
7
+
8
+ ⚠️ **The interesting part is not the copying — it is making "snapshot equals source"
9
+ checkable.** `specs/contracts/install-cli.md` carries the gap explicitly: without a
10
+ reproducible check, "the snapshot matches the source" is an unverifiable statement —
11
+ the shape Constitution Principle I calls a declaration rather than a mechanism. Two
12
+ properties below carry that weight:
13
+
14
+ - **`sync_assets` is idempotent** — running it twice leaves byte-identical results, so
15
+ a second run is a safe no-op rather than a rebuild.
16
+ - **`verify_assets` re-derives the comparison from both trees** — the same function
17
+ the build uses, so the check and the act cannot drift apart.
18
+
19
+ Both are asserted in `tests/test_asset_snapshot.py`.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import filecmp
25
+ import shutil
26
+ from pathlib import Path
27
+
28
+ __all__ = ["ASSET_NAMES", "verify_assets", "sync_assets"]
29
+
30
+ # The distributed trees. Names are relative to the repository root and to `assets/`.
31
+ # `scripts/` is listed once and recurses: `bash/` and `powershell/` are its children,
32
+ # so adding a third shell later needs no change here — and a shell that exists in the
33
+ # source but not in the snapshot is caught by `verify_assets`.
34
+ #
35
+ # ⚠️ **This tuple is asserted LITERALLY** — `tests/test_asset_snapshot.py` →
36
+ # `test_asset_names_is_exactly_the_three_distributed_trees`. Editing it here changes what
37
+ # **every installed project receives**, and that assertion will say so by name. `tools/`
38
+ # in particular is a forbidden zone: it is Layer 4 (maintainers only, FR-019), and no
39
+ # user-facing document mentions it.
40
+ ASSET_NAMES: tuple[str, ...] = ("commands", "templates", "scripts")
41
+
42
+
43
+ def _repo_root_from_assets(assets_dir: Path) -> Path:
44
+ """`<repo>/tools/install/assets` -> `<repo>`."""
45
+ return assets_dir.resolve().parents[2]
46
+
47
+
48
+ def sync_assets(repo_root: Path, assets_dir: Path) -> list[str]:
49
+ """Mirror the distributed trees from `repo_root` into `assets_dir`.
50
+
51
+ Idempotent: the destination trees are removed and recreated, so a second run
52
+ produces a byte-identical snapshot rather than layering changes. Returns the asset
53
+ names synced, for the build log.
54
+
55
+ A missing **top-level** asset is an error, not a silent skip — a snapshot that
56
+ quietly omits `templates/` would ship a CLI that installs nothing, and the wheel
57
+ would build successfully.
58
+ """
59
+ synced: list[str] = []
60
+ assets_dir.mkdir(parents=True, exist_ok=True)
61
+ for name in ASSET_NAMES:
62
+ src = repo_root / name
63
+ if not src.is_dir():
64
+ raise FileNotFoundError(
65
+ f"asset source missing: {src} — the snapshot must not silently omit a "
66
+ f"distributed tree (Constraint 4)"
67
+ )
68
+ dst = assets_dir / name
69
+ if dst.exists():
70
+ shutil.rmtree(dst)
71
+ shutil.copytree(src, dst)
72
+ synced.append(name)
73
+ return synced
74
+
75
+
76
+ def verify_assets(repo_root: Path, assets_dir: Path) -> list[str]:
77
+ """Return the differing relative paths between the source trees and the snapshot.
78
+
79
+ The verification is **file-by-file content comparison**, not a timestamp or size
80
+ heuristic: `copy2` preserves mtime, so a metadata comparison would pass on a
81
+ snapshot whose contents had been edited by hand — which is the one failure this
82
+ exists to catch.
83
+
84
+ An empty list means the snapshot is current.
85
+ """
86
+ differing: list[str] = []
87
+ for name in ASSET_NAMES:
88
+ src = repo_root / name
89
+ dst = assets_dir / name
90
+ if not dst.is_dir():
91
+ differing.append(f"{name} (missing from the snapshot)")
92
+ continue
93
+ _compare(src, dst, name, differing)
94
+ return sorted(differing)
95
+
96
+
97
+ def _compare(src: Path, dst: Path, prefix: str, out: list[str]) -> None:
98
+ comparison = filecmp.dircmp(src, dst)
99
+ out.extend(f"{prefix}/{name}" for name in comparison.left_only)
100
+ out.extend(f"{prefix}/{name} (stale in the snapshot)" for name in comparison.right_only)
101
+ out.extend(f"{prefix}/{name}" for name in comparison.diff_files)
102
+ # `funny_files` are entries that exist on both sides but could not be compared —
103
+ # reporting them as differences is the safe direction.
104
+ out.extend(f"{prefix}/{name} (uncomparable)" for name in comparison.funny_files)
105
+ for name in comparison.common_dirs:
106
+ _compare(src / name, dst / name, f"{prefix}/{name}", out)