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,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,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)
|