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,174 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Template for high-level test tasks (integration / component E2E / app E2E) generated by /specpro-test-plan"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# High-Level Test Tasks: [FEATURE NAME]
|
|
6
|
+
|
|
7
|
+
**Input**: spec.md (FR full set + Acceptance Scenarios + NFR performance metrics), tasks.md (FR→task mapping, module paths), plan.md (Risk-Based Quality Assurance classification, Quality Targets), the ACTUAL codebase (grounding scan)
|
|
8
|
+
**Prerequisites**: spec.md (required), plan.md (recommended), tasks.md (recommended)
|
|
9
|
+
**Executor**: `/specpro-test-implement`
|
|
10
|
+
**Scope Boundary**:
|
|
11
|
+
- **Unit tests are OUT of scope for this file** — unit tests are owned by tasks.md (including `core/src` embedded unit tests, registered into the matrix by the grounding scan)
|
|
12
|
+
- **Manual acceptance is OUT of scope for this file** — subjective/exploratory scenarios are routed to `/specpro-manual-test` (manual-test-tasks.md)
|
|
13
|
+
|
|
14
|
+
**Artifact Language Rule** 🌐: prose content (descriptions, rationales, scenario text) follows the project's Artifact Language setting; structural anchors — section headings below, task ID prefixes (INF/IT/CE/AE), FR/US IDs, the status enum, and table column names — are ALWAYS English, exactly as written in this template. **Entry field labels are anchors too** (`source:`, `Evidence:`, `Fix Direction:`) — never translated: they exist to be searched, and a translated label drops out of every consumer's scan.
|
|
15
|
+
|
|
16
|
+
**Test-plan Runs** (append one entry per `/specpro-test-plan` run; isomorphic to spec.md Revision History):
|
|
17
|
+
- [test-plan:create YYYY-MM-DD] full mode, [N] FRs, [M] tasks
|
|
18
|
+
<!-- Incremental run examples:
|
|
19
|
+
- [test-plan:incremental YYYY-MM-DD] FR-xxx(Modified) → IT-004 rewritten; FR-yyy(deleted) → row deprecated
|
|
20
|
+
-->
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Coverage Matrix (built BEFORE any task generation)
|
|
25
|
+
|
|
26
|
+
**⚠️ CRITICAL**: one row per FR; every uncovered cell MUST carry a rationale (e.g., "pure UI, app-level" or "covered by tasks.md T-xxx unit tests") — never leave a cell blank.
|
|
27
|
+
|
|
28
|
+
| FR | Domain | Unit | Integration | Component E2E | App E2E | Task IDs | Status |
|
|
29
|
+
|----|--------|------|-------------|---------------|---------|----------|--------|
|
|
30
|
+
| FR-xxx | handshake | tasks.md T-xxx unit coverage | IT-001 | CE-001 | AE-001 | IT-001, CE-001 | not-started |
|
|
31
|
+
| FR-xxx | decode | core/src XxxDecoderTest | IT-002 | — (byte-level only, integration) | AE-001 | IT-002 | not-started |
|
|
32
|
+
| FR-xxx | UI layout | — (pure UI logic, low risk) | — (pure UI, app-level) | — (no standalone component chain) | AE-003 | AE-003 | not-started |
|
|
33
|
+
| FR-xxx | YAML config | PluginConfigurationTest | — (scripted file IO adds little value) | — | — | — (unit coverage sufficient) | not-applicable |
|
|
34
|
+
| FR-xxx | feature not yet implemented | pending-impl | pending-impl | pending-impl | pending-impl | - | pending-impl |
|
|
35
|
+
|
|
36
|
+
**Column notes**:
|
|
37
|
+
- **Unit**: register existing tasks.md unit tasks or `core/src` embedded tests (grounding scan output); this file NEVER creates new unit tasks
|
|
38
|
+
- **Integration / Component E2E / App E2E**: task IDs; when not applicable a rationale is MANDATORY
|
|
39
|
+
- **Task IDs**: consolidated IDs of this file's tasks hooked to the FR (updated in sync during execution)
|
|
40
|
+
- **Status** (single execution semantics; writers and triggers per the evolution rules below):
|
|
41
|
+
* `pending-impl` (no implementation component yet — **generate NO tasks**, never test things that don't exist) — test-plan planning verdict, immutable during execution
|
|
42
|
+
* `manual` (routed to `/specpro-manual-test`, with the subjective REMNANT named in the rationale) — test-plan planning verdict, immutable during execution. **Prerequisite (FR-065)**: before routing an FR here, automation MUST first be ruled out — evaluate the criterion body against the project's mechanical carriers (text-criterion assertions · extract-and-run of in-doc executable bodies · behavioural replay); every mechanically checkable part MUST get an IT row, and the `manual` row carries ONLY the subjective remnant, named concretely. "No unit execution body" alone MUST NOT justify this routing
|
|
43
|
+
* `not-applicable` (unit coverage sufficient, no high-level task needed) — test-plan planning verdict, immutable during execution
|
|
44
|
+
* `not-started` → `in-progress` → `completed` — advanced by `/specpro-test-implement` (derived from task checkboxes); a parenthetical annotation is allowed for refinement (e.g., `not-started (decode sub-item pending)`) — statistics MUST stay compatible
|
|
45
|
+
* `deprecated` (FR deleted from spec; row kept for traceability, hooked tasks marked `[DELETED]`) — test-plan incremental write
|
|
46
|
+
|
|
47
|
+
**Status evolution rules** (who changes which value when):
|
|
48
|
+
|
|
49
|
+
| Status | Writer | Trigger |
|
|
50
|
+
|--------|--------|---------|
|
|
51
|
+
| pending-impl / manual / not-applicable | `/specpro-test-plan` | Planning verdict (grounding scan + layer assignment), immutable during execution |
|
|
52
|
+
| not-started → in-progress → completed | `/specpro-test-implement` | Checkbox-derived: ALL hooked tasks `[x]` → `completed`; SOME → `in-progress`; failures never advance |
|
|
53
|
+
| deprecated | `/specpro-test-plan` (incremental) | FR deleted from spec (SpecStatus=delete) |
|
|
54
|
+
|
|
55
|
+
**Matrix Change Log** (the processing record for incremental mode; every re-processed FR is logged here — rows absent from the log are unchanged since the last run):
|
|
56
|
+
|
|
57
|
+
| Date | FR | Change | Trigger | Affected tasks |
|
|
58
|
+
|------|-----|--------|---------|----------------|
|
|
59
|
+
| YYYY-MM-DD | all | create | initial generation | (task ID range) |
|
|
60
|
+
<!-- Incremental examples:
|
|
61
|
+
| YYYY-MM-DD | FR-xxx | update | spec FR-xxx modified (priority adjusted) | IT-004 rewritten in place |
|
|
62
|
+
| YYYY-MM-DD | FR-yyy | delete | FR-yyy removed from spec | row deprecated; IT-011 [DELETED] |
|
|
63
|
+
-->
|
|
64
|
+
<!-- Task removal convention (same as tasks.md): when a spec change invalidates a task, mark `[DELETED]` without physical removal; the next incremental run cleans up -->
|
|
65
|
+
<!-- Incremental generation rule: NO multi-phase Lifecycle markers on rows in this file — execution progress derives from task checkboxes (the Status column is a readable summary); planning generations are carried by "Test-plan Runs + Matrix Change Log" -->
|
|
66
|
+
|
|
67
|
+
**Layer assignment rules** (detail in the `/specpro-test-plan` command):
|
|
68
|
+
- **Integration**: peer behavior deterministically simulated with byte sequences / scripts
|
|
69
|
+
- **Component E2E**: depends on real peer quirks / timing / performance / concurrency scale
|
|
70
|
+
- **App E2E**: app assembly, UI rendering chain, input-to-display
|
|
71
|
+
- **Subjective/exploratory**: routed to `/specpro-manual-test` (manual) — ONLY after automation is ruled out per FR-065 (mechanical parts → IT rows; the manual row names the subjective remnant)
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Peer Diversity Registry [only for protocol / wire-format modules]
|
|
76
|
+
|
|
77
|
+
**Purpose**: the authoritative, closed set of **measured** peer behaviours. Any test expectation that asserts "peer X behaves as Y" MUST trace to a row here. **Absence means unmeasured — unmeasured peer behaviour MUST NOT be frozen into an expectation.**
|
|
78
|
+
|
|
79
|
+
| Peer | Version | Measured behaviour | Basis (how it was measured) | Date | Affects |
|
|
80
|
+
|------|---------|--------------------|------------------------------|------|---------|
|
|
81
|
+
| [peer] | [version] | [observed behaviour, including quirks] | [capture / probe / reference implementation] | YYYY-MM-DD | [FR-xxx / task ID] |
|
|
82
|
+
|
|
83
|
+
<!-- The KIND of difference this registry exists to record: an optional field present in one
|
|
84
|
+
implementation and absent in another; compile-time options that change wire output;
|
|
85
|
+
environment-dependent behaviour. Record what was MEASURED for your peers — do not copy
|
|
86
|
+
these examples as facts. -->
|
|
87
|
+
|
|
88
|
+
**Ownership**: written ONLY by `/specpro-test-plan` (same boundary as the coverage matrix and the two logs). Differences discovered while executing tests are reported by `/specpro-test-implement` and registered on the next incremental `test-plan` run.
|
|
89
|
+
|
|
90
|
+
**Audited by**: `analyze` Pass L5 — an expectation that asserts peer behaviour with no row here is a finding.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Test Infrastructure Tasks (INF-xxx, first)
|
|
95
|
+
|
|
96
|
+
**⚠️ INF tasks MUST complete before ALL test tasks** (e.g., mock servers, test container manifests, test fixtures). INF tasks carry NO FR annotation.
|
|
97
|
+
|
|
98
|
+
- [ ] INF-001 [infra] Build an in-process mock server (supports 003.003/003.007/003.008 version branches)
|
|
99
|
+
Location: tests/integration-test/src/jvmTest/fixtures/MockPeerServer.kt
|
|
100
|
+
Purpose: byte-level scriptable mock peer for IT tasks
|
|
101
|
+
Validates: replays handshake / security-type / encoding-negotiation byte sequences per script
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Integration Test Tasks (IT-xxx, grouped by scenario/domain)
|
|
106
|
+
|
|
107
|
+
### [handshake]
|
|
108
|
+
|
|
109
|
+
- [ ] IT-001 [handshake] protocol version-branch handshake (FR-xxx)
|
|
110
|
+
Location: tests/integration-test/src/jvmTest/kotlin/.../VersionMatrixIT.kt
|
|
111
|
+
Source: mock server returns 003.003/003.007/003.008; assert the client uses the matching security handshake format
|
|
112
|
+
Mock: in-process ServerSocket mock server
|
|
113
|
+
<!-- Optional extensions: Test Cases / Risk Level / Execution Time(<30s) / CI/CD -->
|
|
114
|
+
|
|
115
|
+
### [encoding-negotiation]
|
|
116
|
+
|
|
117
|
+
<!-- - [ ] IT-002 [encoding-negotiation] ... (FR-xxx) -->
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Component E2E Tasks (CE-xxx)
|
|
122
|
+
|
|
123
|
+
- [ ] CE-001 [connection-matrix] Real-server auth + full encoding flow (FR-xxx, FR-yyy)
|
|
124
|
+
Location: tests/e2e-test/src/test/kotlin/.../RealServerConnectionE2ETest.kt
|
|
125
|
+
Environment: Docker + container manifest (e.g., docker-compose.test.yml)
|
|
126
|
+
Setup: 1. start the server container 2. @BeforeAll port probing, assumeTrue-skip when unavailable
|
|
127
|
+
Validates: handshake → auth → encoding negotiation → first-frame decode completes against a REAL peer
|
|
128
|
+
Teardown: 1. stop and remove containers
|
|
129
|
+
Performance: first frame <2s (optional; REQUIRED only when a corresponding NFR exists)
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Application E2E Tasks (AE-xxx)
|
|
134
|
+
|
|
135
|
+
- [ ] AE-001 [assembly] App startup → connect → remote screen rendering (FR-xxx, FR-yyy)
|
|
136
|
+
Location: tests/e2e-test/src/test/kotlin/.../AppAssemblyE2ETest.kt
|
|
137
|
+
Environment: Desktop app build artifact (grounded build command; launch mechanism + display environment, virtual display for headless) + Docker server
|
|
138
|
+
Setup: 1. build the app 2. start the server container 3. port-probe guard 4. launch the app
|
|
139
|
+
Validates: the full input-event-to-screen-display chain (app assembly + UI rendering)
|
|
140
|
+
Teardown: 1. close the app 2. clean up containers
|
|
141
|
+
Performance: input-to-display <500ms (optional; REQUIRED only when a corresponding NFR exists)
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Progress Statistics
|
|
146
|
+
|
|
147
|
+
| Layer | Total | Done | Rate |
|
|
148
|
+
|-------|-------|------|------|
|
|
149
|
+
| Infra (INF) | 0 | 0 | 0% |
|
|
150
|
+
| Integration (IT) | 0 | 0 | 0% |
|
|
151
|
+
| Component E2E (CE) | 0 | 0 | 0% |
|
|
152
|
+
| App E2E (AE) | 0 | 0 | 0% |
|
|
153
|
+
| **Total** | 0 | 0 | 0% |
|
|
154
|
+
|
|
155
|
+
**Matrix coverage**: 0 FRs registered / 0 pending-impl / 0 manual
|
|
156
|
+
|
|
157
|
+
<!--
|
|
158
|
+
============================================================================
|
|
159
|
+
Execution rules (for /specpro-test-implement):
|
|
160
|
+
1. Execution order: INF → IT → CE → AE (default when the matrix declares no explicit dependencies)
|
|
161
|
+
2. Per completed task: check [x] AND sync the Coverage Matrix "Task IDs/Status" columns plus Progress Statistics
|
|
162
|
+
3. When the E2E environment is unavailable, SKIP via the assumeTrue guard — count as skip, not fail
|
|
163
|
+
4. A failed task is NEVER checked [x]; its matrix status stays not-started / in-progress
|
|
164
|
+
5. Cells routed to manual are annotated with /specpro-manual-test coverage
|
|
165
|
+
6. Only advance not-started / in-progress → completed; NEVER touch pending-impl / manual / not-applicable / deprecated rows (planning verdicts belong to test-plan)
|
|
166
|
+
7. Tasks marked [DELETED] are skipped during execution
|
|
167
|
+
8. Execution NEVER writes "Matrix Change Log" or "Test-plan Runs" — both logs are owned by /specpro-test-plan
|
|
168
|
+
|
|
169
|
+
Iteration rules (for /specpro-test-plan incremental mode):
|
|
170
|
+
9. Incremental scope = FRs logged in the Matrix Change Log ∪ FRs with changed SpecStatus in spec.md ∪ new grounding-scan findings;
|
|
171
|
+
all other matrix rows carry over verbatim (rationales, task IDs, statuses)
|
|
172
|
+
10. FR deleted → row deprecated, tasks marked [DELETED]; FR modified → row re-adjudicated, tasks rewritten in place (numbering NEVER resets)
|
|
173
|
+
============================================================================
|
|
174
|
+
-->
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""`specpro init` — deploy specpro's assets into a target project.
|
|
2
|
+
|
|
3
|
+
`specs/contracts/install-cli.md` fixes this module's job and the order it must run in.
|
|
4
|
+
Three rules from that contract drive the shape below:
|
|
5
|
+
|
|
6
|
+
- **全部校验通过才开始落盘** (§ Pre-write Validation). Every check runs before the first
|
|
7
|
+
write, because a failure part-way through leaves a half-installed project — the one
|
|
8
|
+
outcome the ordering exists to prevent.
|
|
9
|
+
- **第 4 项不中止** (same section). A file conflict is *skipped and reported*, never
|
|
10
|
+
fatal: a user who edited one of twenty files must still receive the other nineteen.
|
|
11
|
+
- **入口层 MUST NOT 硬编码任何 agent 名、路径或格式** (§ 入口层的职责边界). Every name and
|
|
12
|
+
every path is queried from the registry, so "add an agent = add one subclass" stays
|
|
13
|
+
true (FR-003). This file is where a hardcoded name would break that.
|
|
14
|
+
|
|
15
|
+
**Scope of this revision**: the flow skeleton, the pre-write validation block (all four
|
|
16
|
+
items — T027's two checks were part of the skeleton), candidate detection (T026), the
|
|
17
|
+
per-file conflict gate with its `--force` override (T028, delegated to `setup`), and the
|
|
18
|
+
output-contract report (T029). Still open: the uninstall path (T031 — held pending a
|
|
19
|
+
contract-level ruling on its command interface) and the in-repo fallback install path
|
|
20
|
+
(T032, `tools/install/fallback/`).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import shutil
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
import typer
|
|
31
|
+
|
|
32
|
+
from specpro_cli.integrations import registry
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
35
|
+
from specpro_cli.integrations.base import IntegrationBase
|
|
36
|
+
|
|
37
|
+
# The floor declared in `pyproject.toml` (`requires-python`). Repeated here as a value
|
|
38
|
+
# rather than read from package metadata, because the check must also work in a source
|
|
39
|
+
# checkout where no distribution is installed.
|
|
40
|
+
_MIN_PYTHON = (3, 11)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------------------
|
|
44
|
+
# Pre-write validation — steps 1–3 of the contract's four
|
|
45
|
+
# ---------------------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _check_runtime() -> None:
|
|
49
|
+
"""Step 1 — the CLI's own runtime prerequisite (FR-002, 宪法 2.3).
|
|
50
|
+
|
|
51
|
+
⚠️ **This runs before anything else and exits rather than warns.** 宪法 2.3 states
|
|
52
|
+
the enforcement point literally: a missing runtime dependency MUST exit before any
|
|
53
|
+
file is written, with the exact command that installs it. A warning would leave the
|
|
54
|
+
user inside an install that is about to fail at some later, less legible point.
|
|
55
|
+
|
|
56
|
+
The message names a command, not advice — 「请安装 Python 3.11+」 is a description of a
|
|
57
|
+
state, and the contract asks for the action that reaches it. The interpreter is named
|
|
58
|
+
per platform because the name genuinely differs.
|
|
59
|
+
"""
|
|
60
|
+
if sys.version_info[:2] >= _MIN_PYTHON:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
have = ".".join(str(p) for p in sys.version_info[:3])
|
|
64
|
+
want = ".".join(str(p) for p in _MIN_PYTHON)
|
|
65
|
+
if sys.platform.startswith("win"):
|
|
66
|
+
command = f"py -{want} -m pip install --upgrade specpro-cli"
|
|
67
|
+
alternative = f"winget install Python.Python.{want}"
|
|
68
|
+
else:
|
|
69
|
+
command = f"python{want} -m pip install --upgrade specpro-cli"
|
|
70
|
+
alternative = "brew install python@3.11" if sys.platform == "darwin" else \
|
|
71
|
+
"sudo apt install python3.11 python3.11-venv"
|
|
72
|
+
_abort(
|
|
73
|
+
f"specpro needs Python {want} or newer; this is {have}.",
|
|
74
|
+
"No files were written.",
|
|
75
|
+
f"Install it with: {command}",
|
|
76
|
+
f"Or get the interpreter first: {alternative}",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _resolve_agent(explicit: str, project_root: Path) -> IntegrationBase:
|
|
81
|
+
"""Step 2 — pick the integration to install for.
|
|
82
|
+
|
|
83
|
+
With `--agent` given, the key must be registered (FR-005: an unknown key aborts
|
|
84
|
+
with the full valid set printed, not merely referred to — a key is the agent's
|
|
85
|
+
binary name, and a typo is indistinguishable from an unsupported agent without
|
|
86
|
+
the list).
|
|
87
|
+
|
|
88
|
+
Without it, detection runs (T026): exactly one hit installs for that agent;
|
|
89
|
+
several hits abort and demand an explicit choice, because "guess, then silently
|
|
90
|
+
install into the wrong agent's directory" is precisely the failure FR-004 exists
|
|
91
|
+
to prevent; zero hits aborts with the registered set, which is the honest report
|
|
92
|
+
when nothing on disk names a supported agent.
|
|
93
|
+
|
|
94
|
+
⚠️ **The detection walk is `entry.detect_candidates`, not a second copy.** The
|
|
95
|
+
entry module already owns that lookup (with its own tests); importing it here —
|
|
96
|
+
lazily, since `entry` imports this module to register the command — keeps one
|
|
97
|
+
detection rule. A duplicated walk would drift the first time someone tuned one
|
|
98
|
+
of them.
|
|
99
|
+
"""
|
|
100
|
+
if not explicit:
|
|
101
|
+
# Deferred import: `entry` imports this module at load time (the command
|
|
102
|
+
# registration), so the import must wait until both modules are loaded.
|
|
103
|
+
from specpro_cli.cli.entry import detect_candidates
|
|
104
|
+
|
|
105
|
+
keys = registry.keys()
|
|
106
|
+
candidate_keys = detect_candidates(project_root)
|
|
107
|
+
if len(candidate_keys) == 1:
|
|
108
|
+
only = registry.get(candidate_keys[0])
|
|
109
|
+
if only is not None: # pragma: no cover — detect_candidates reads the same map
|
|
110
|
+
display = (only.config or {}).get("name") or only.key
|
|
111
|
+
typer.secho(
|
|
112
|
+
f"detect: found {only.key} ({display}) — installing for it "
|
|
113
|
+
"(pass --agent to override)",
|
|
114
|
+
fg=typer.colors.CYAN,
|
|
115
|
+
err=True,
|
|
116
|
+
)
|
|
117
|
+
return only
|
|
118
|
+
if len(candidate_keys) > 1:
|
|
119
|
+
_abort(
|
|
120
|
+
"several agents detected in this project — specpro will not pick one.",
|
|
121
|
+
"Detected: " + ", ".join(candidate_keys),
|
|
122
|
+
f"All registered keys: {', '.join(keys) if keys else '(none imported yet)'}",
|
|
123
|
+
"Re-run with: specpro init --agent <key>",
|
|
124
|
+
)
|
|
125
|
+
_abort(
|
|
126
|
+
"no --agent given, and no supported agent detected in this project.",
|
|
127
|
+
f"Registered agent keys: {', '.join(keys) if keys else '(none imported yet)'}",
|
|
128
|
+
"Re-run with: specpro init --agent <key>",
|
|
129
|
+
)
|
|
130
|
+
integration = registry.get(explicit)
|
|
131
|
+
if integration is None:
|
|
132
|
+
keys = registry.keys()
|
|
133
|
+
_abort(
|
|
134
|
+
f"unknown agent {explicit!r}.",
|
|
135
|
+
f"Valid keys: {', '.join(keys) if keys else '(none imported yet)'}",
|
|
136
|
+
# FR-005: the valid set is printed, not merely referred to. An agent key is
|
|
137
|
+
# the agent's binary name — a typo is indistinguishable from an unsupported
|
|
138
|
+
# agent without the list.
|
|
139
|
+
)
|
|
140
|
+
return integration
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _check_writable(integration: IntegrationBase, project_root: Path) -> Path:
|
|
144
|
+
"""Step 3 — the deploy root can be created, or exists and is writable.
|
|
145
|
+
|
|
146
|
+
Checked by *attempting* the mkdir rather than by inspecting permissions: permission
|
|
147
|
+
bits do not decide writability on every filesystem (ACLs, read-only mounts, Windows),
|
|
148
|
+
so an inspection would report "writable" for paths the following write would reject.
|
|
149
|
+
The `mkdir` is the only side effect this command performs before the full validation
|
|
150
|
+
block has passed, and it is idempotent — it creates an empty directory at the
|
|
151
|
+
integration's own declared root, never a file.
|
|
152
|
+
"""
|
|
153
|
+
dest = integration.commands_dest(project_root)
|
|
154
|
+
try:
|
|
155
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
except OSError as exc:
|
|
157
|
+
_abort(
|
|
158
|
+
f"cannot create the deploy root: {dest}",
|
|
159
|
+
f"Reason: {exc}",
|
|
160
|
+
"No files were written.",
|
|
161
|
+
)
|
|
162
|
+
if not dest.is_dir():
|
|
163
|
+
_abort(f"deploy root is not a directory: {dest}", "No files were written.")
|
|
164
|
+
return dest
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------------------
|
|
168
|
+
# Helpers
|
|
169
|
+
# ---------------------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _is_interactive() -> bool:
|
|
173
|
+
"""Whether prompts may be shown (FR-070/FR-071 TTY guard). Module-level so tests
|
|
174
|
+
can inject it — CliRunner replaces `sys.stdin` wholesale, so patching the stream's
|
|
175
|
+
own `isatty` never lands."""
|
|
176
|
+
return sys.stdin.isatty()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _abort(message: str, *detail_lines: str) -> None:
|
|
180
|
+
"""Report a blocking condition and exit non-zero. Writes nothing — by construction.
|
|
181
|
+
|
|
182
|
+
Every caller of this function runs inside the pre-write validation block, so "abort"
|
|
183
|
+
and "leave the project untouched" are the same act.
|
|
184
|
+
"""
|
|
185
|
+
typer.secho(f"✗ {message}", fg=typer.colors.RED, err=True)
|
|
186
|
+
for line in detail_lines:
|
|
187
|
+
typer.secho(f" {line}", err=True)
|
|
188
|
+
raise typer.Exit(code=1)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def assets_commands_dir(source: Path | None = None) -> Path:
|
|
192
|
+
"""Where the command templates are read from.
|
|
193
|
+
|
|
194
|
+
`--source` wins when given (development and verification against a checkout).
|
|
195
|
+
Otherwise the snapshot baked into the wheel at build time is used. ⚠️ **The built-in
|
|
196
|
+
snapshot MUST stay the default**: making `--source` the default would put installation
|
|
197
|
+
back on a network or a checkout, which is the entire reason the snapshot exists
|
|
198
|
+
(`install-cli.md` → `## Asset Sourcing` → `### Explicitly Ruled Out`).
|
|
199
|
+
"""
|
|
200
|
+
if source is not None:
|
|
201
|
+
root = source.expanduser().resolve()
|
|
202
|
+
# A `--source` may point either at a repository root (which contains
|
|
203
|
+
# `commands/`) or directly at a commands directory. Both are accepted; there is
|
|
204
|
+
# no third form, so no further guessing.
|
|
205
|
+
return root / "commands" if (root / "commands").is_dir() else root
|
|
206
|
+
|
|
207
|
+
from importlib.resources import files
|
|
208
|
+
|
|
209
|
+
return Path(str(files("specpro_cli"))) / "assets" / "commands"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# FR-066 — the deployment directory for the template/script support groups.
|
|
213
|
+
# T258 lands the NEW name directly (plan.md → CLI UX & Deployment Layout: the
|
|
214
|
+
# three-group expansion and the global rename land in the same round, so the
|
|
215
|
+
# support groups never live under the old name).
|
|
216
|
+
SUPPORT_DIR_NAME = ".specpro"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _repo_root_form(source: Path) -> bool:
|
|
220
|
+
"""A `--source` pointing at a repository root (vs. directly at a commands dir)."""
|
|
221
|
+
return (source / "commands").is_dir()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _assets_group_dir(group: str, source: Path | None) -> Path | None:
|
|
225
|
+
"""Source of one support group (`templates`, `scripts/bash`).
|
|
226
|
+
|
|
227
|
+
Snapshot mode: `specpro_cli/assets/<group>` (always present — the wheel bakes
|
|
228
|
+
all three groups). `--source` repository-root mode: `<root>/<group>`.
|
|
229
|
+
A `--source` pointing DIRECTLY at a commands directory provides no support
|
|
230
|
+
groups — that form promises only commands — so this returns None there and the
|
|
231
|
+
caller reports the groups as not provided instead of aborting (the two origins
|
|
232
|
+
are the only two shapes; there is no third form to guess).
|
|
233
|
+
"""
|
|
234
|
+
if source is None:
|
|
235
|
+
from importlib.resources import files
|
|
236
|
+
|
|
237
|
+
return Path(str(files("specpro_cli"))) / "assets" / group
|
|
238
|
+
root = source.expanduser().resolve()
|
|
239
|
+
return root / group if _repo_root_form(root) else None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _deploy_verbatim(src: Path, dest: Path, *, force: bool) -> tuple[list[Path], list[Path]]:
|
|
243
|
+
"""Copy one support group byte-for-byte. Returns `(written, skipped)`.
|
|
244
|
+
|
|
245
|
+
Templates and scripts are the byte-identical tier (FR-007) — no format
|
|
246
|
+
processing here, mirroring the layering rule that keeps format conversion in
|
|
247
|
+
the integration base class. The per-file conflict gate matches `setup()`'s:
|
|
248
|
+
an existing file without `--force` is skipped and named, never silently
|
|
249
|
+
overwritten.
|
|
250
|
+
"""
|
|
251
|
+
written: list[Path] = []
|
|
252
|
+
skipped: list[Path] = []
|
|
253
|
+
for f in sorted(src.rglob("*")):
|
|
254
|
+
if not f.is_file():
|
|
255
|
+
continue
|
|
256
|
+
target = dest / f.relative_to(src)
|
|
257
|
+
if target.exists() and not force:
|
|
258
|
+
skipped.append(target)
|
|
259
|
+
continue
|
|
260
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
shutil.copy2(f, target)
|
|
262
|
+
written.append(target)
|
|
263
|
+
return written, skipped
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ---------------------------------------------------------------------------------------
|
|
267
|
+
# The command
|
|
268
|
+
# ---------------------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def cmd_init(
|
|
272
|
+
path: Path = typer.Argument(
|
|
273
|
+
Path("."),
|
|
274
|
+
help="Target project root.",
|
|
275
|
+
show_default=False,
|
|
276
|
+
),
|
|
277
|
+
agent: str = typer.Option(
|
|
278
|
+
"", "--agent", help="Target agent key (default: detect from the project)."
|
|
279
|
+
),
|
|
280
|
+
force: bool = typer.Option(
|
|
281
|
+
False, "--force", help="Overwrite files that already exist."
|
|
282
|
+
),
|
|
283
|
+
source: Path | None = typer.Option(
|
|
284
|
+
None, "--source", help="Read assets from this path instead of the built-in snapshot."
|
|
285
|
+
),
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Deploy specpro into a project, for one AI coding agent."""
|
|
288
|
+
project_root = path.expanduser().resolve()
|
|
289
|
+
|
|
290
|
+
# --- interactive gating (TTY only) ---------------------------------------------------
|
|
291
|
+
# FR-070 / FR-071. A prompt MUST NOT hang a non-TTY run (scripts/CI): there the
|
|
292
|
+
# flow falls through to the explicit-argument rules below (--agent given, or the
|
|
293
|
+
# detection/abort logic of FR-004/FR-005).
|
|
294
|
+
interactive = _is_interactive()
|
|
295
|
+
if interactive:
|
|
296
|
+
typer.echo(f"target project: {project_root}")
|
|
297
|
+
if not typer.confirm("initialize specpro here?", default=True):
|
|
298
|
+
typer.echo("Aborted — cd to the target project and re-run. Nothing was written.")
|
|
299
|
+
raise typer.Exit()
|
|
300
|
+
if not agent:
|
|
301
|
+
# Registry-driven numbered list; this layer still hardcodes no name.
|
|
302
|
+
keys = sorted(registry.registered().keys())
|
|
303
|
+
if keys:
|
|
304
|
+
typer.echo("select the AI coding agent:")
|
|
305
|
+
for i, key in enumerate(keys, 1):
|
|
306
|
+
typer.echo(f" {i}. {key}")
|
|
307
|
+
choice = typer.prompt("number", default=1, type=int)
|
|
308
|
+
if not 1 <= choice <= len(keys):
|
|
309
|
+
_abort(f"selection {choice} is out of range 1..{len(keys)}")
|
|
310
|
+
agent = keys[choice - 1]
|
|
311
|
+
|
|
312
|
+
# --- pre-write validation: all of it, before any of the writing ---------------------
|
|
313
|
+
_check_runtime() # 1 FR-002
|
|
314
|
+
integration = _resolve_agent(agent, project_root) # 2 FR-004 / FR-005
|
|
315
|
+
dest = _check_writable(integration, project_root) # 3 FR-005
|
|
316
|
+
|
|
317
|
+
commands_dir = assets_commands_dir(source)
|
|
318
|
+
if not commands_dir.is_dir():
|
|
319
|
+
_abort(
|
|
320
|
+
f"command templates not found at {commands_dir}",
|
|
321
|
+
"The built-in snapshot is created at build time; in a source checkout "
|
|
322
|
+
"pass --source <path-to-repository-root>.",
|
|
323
|
+
"No files were written.",
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# FR-001 — the install entry deploys ALL THREE groups in one invocation
|
|
327
|
+
# (commands via the integration, templates and support scripts via the
|
|
328
|
+
# `.specpro/` support directory). Every source is resolved HERE, in the
|
|
329
|
+
# pre-write block: an abort after the commands group had landed would break
|
|
330
|
+
# the zero-writes promise in the other direction.
|
|
331
|
+
support_groups: list[tuple[str, Path, Path | None]] = []
|
|
332
|
+
for group in ("templates", "scripts/bash"):
|
|
333
|
+
src = _assets_group_dir(group, source)
|
|
334
|
+
# ⚠️ `group_dest`, NOT `dest` — `dest` is the integration's deploy root returned
|
|
335
|
+
# by `_check_writable` above and printed in the report; shadowing it here made
|
|
336
|
+
# `deployed to:` name the last support group (caught by T258's own test).
|
|
337
|
+
group_dest = project_root / SUPPORT_DIR_NAME / group
|
|
338
|
+
if src is not None and not src.is_dir():
|
|
339
|
+
_abort(
|
|
340
|
+
f"{group} assets not found at {src}",
|
|
341
|
+
"The built-in snapshot is created at build time; in a source checkout "
|
|
342
|
+
"pass --source <path-to-repository-root>.",
|
|
343
|
+
"No files were written.",
|
|
344
|
+
)
|
|
345
|
+
support_groups.append((group, group_dest, src))
|
|
346
|
+
|
|
347
|
+
# --- deploy -------------------------------------------------------------------------
|
|
348
|
+
# ⚠️ **The writing is the integration's, not this layer's.** `install-cli.md` →
|
|
349
|
+
# 「入口层的职责边界」 puts it in one line: *MUST NOT 包含格式转换逻辑 —— 那是格式基类的
|
|
350
|
+
# 职责*. An entry layer that looped over templates and wrote files itself would look
|
|
351
|
+
# correct for the Markdown family and be **wrong for every other one**: it would skip
|
|
352
|
+
# the skill header, the per-agent field an integration injects through its override
|
|
353
|
+
# hook, and the TOML/YAML rendering — and it would do so silently, leaving files that
|
|
354
|
+
# exist and are the wrong shape.
|
|
355
|
+
#
|
|
356
|
+
# ⚠️ Note the wording above: this comment may not name an agent. The boundary is
|
|
357
|
+
# enforced mechanically (`tests/test_cli_entry.py` scans this directory for every
|
|
358
|
+
# first-batch agent key and deploy root), and it is right to — a name in a comment is
|
|
359
|
+
# still the entry layer having learned about one agent.
|
|
360
|
+
#
|
|
361
|
+
# Step 4 of the validation block (per-file conflict → skip, do not abort) is
|
|
362
|
+
# delegated to `setup` itself and travels with the format logic it belongs to; the
|
|
363
|
+
# entry layer only forwards `--force` and reports what comes back.
|
|
364
|
+
written, skipped = integration.setup(project_root, commands_dir, force=force)
|
|
365
|
+
|
|
366
|
+
# --- support groups (byte-identical tier, FR-007: plain copy) ------------------------
|
|
367
|
+
# src is None ⇔ a commands-only --source; the group is then NOT PROVIDED by
|
|
368
|
+
# that origin, and the report says so — it is never a silent absence.
|
|
369
|
+
support_report: list[tuple[str, list[Path], list[Path], str]] = []
|
|
370
|
+
for group, group_dest, src in support_groups:
|
|
371
|
+
if src is None:
|
|
372
|
+
support_report.append((group, [], [], "not provided by this --source (commands-only)"))
|
|
373
|
+
continue
|
|
374
|
+
g_written, g_skipped = _deploy_verbatim(src, group_dest, force=force)
|
|
375
|
+
support_report.append((group, g_written, g_skipped, str(group_dest)))
|
|
376
|
+
|
|
377
|
+
# --- report -------------------------------------------------------------------------
|
|
378
|
+
# Shape fixed by `install-cli.md` → `## Output Contract`: target · agent · written ·
|
|
379
|
+
# skipped WITH every file named and its reason · how to invoke. 逐条列出 is the hard
|
|
380
|
+
# requirement there — a bare count leaves the user unable to tell WHICH files he
|
|
381
|
+
# must handle himself, and "which files did not install" is exactly the thing he
|
|
382
|
+
# needs to judge.
|
|
383
|
+
display = (integration.config or {}).get("name") or integration.key
|
|
384
|
+
template_names = [p.name for p in integration.list_command_templates(commands_dir)]
|
|
385
|
+
|
|
386
|
+
typer.echo(f"specpro → {project_root}")
|
|
387
|
+
typer.echo(f"agent: {integration.key} ({display})")
|
|
388
|
+
typer.echo(f"deployed to: {dest}")
|
|
389
|
+
typer.echo(f"written: {len(written)}")
|
|
390
|
+
if skipped:
|
|
391
|
+
typer.echo(f"skipped: {len(skipped)} (already exist; use --force to overwrite)")
|
|
392
|
+
for p in skipped:
|
|
393
|
+
typer.echo(f" - {p}")
|
|
394
|
+
else:
|
|
395
|
+
typer.echo("skipped: 0")
|
|
396
|
+
# Output Contract (install-cli.md): skipped files are named, never a bare count —
|
|
397
|
+
# the same rule the commands group follows above applies to each support group.
|
|
398
|
+
for group, g_written, g_skipped, note in support_report:
|
|
399
|
+
if isinstance(note, str) and note.startswith("not provided"):
|
|
400
|
+
typer.echo(f"support: {group} — {note}")
|
|
401
|
+
continue
|
|
402
|
+
typer.echo(
|
|
403
|
+
f"support: {group} — written {len(g_written)}, skipped {len(g_skipped)}"
|
|
404
|
+
+ (" (already exist; use --force to overwrite)" if g_skipped else "")
|
|
405
|
+
+ f" → {note}"
|
|
406
|
+
)
|
|
407
|
+
for p in g_skipped:
|
|
408
|
+
typer.echo(f" - {p}")
|
|
409
|
+
typer.echo("")
|
|
410
|
+
typer.echo("Next: invoke specpro commands from this agent as")
|
|
411
|
+
for name in template_names:
|
|
412
|
+
# `build_command_invocation` takes the COMMAND name (`specpro.analyze`), not the
|
|
413
|
+
# template file name — passing the raw name emitted `/specpro.analyze.md`, a
|
|
414
|
+
# suffix no agent parses. `Path.stem` strips the extension for every format.
|
|
415
|
+
stem = Path(name).stem
|
|
416
|
+
typer.echo(f" {integration.build_command_invocation(stem)} # {name}")
|