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,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,5 @@
1
+ """Entry layer — argument parsing and flow orchestration.
2
+
3
+ Holds no agent names, paths, or format knowledge: those live in `integrations/`
4
+ and are reached only through the registry (see specs/plan.md -> Pattern 2).
5
+ """
@@ -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}")