vstack 0.0.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.
- vstack/__init__.py +5 -0
- vstack/__main__.py +5 -0
- vstack/_templates/agents/_partials/agent-skill-boundary.md +5 -0
- vstack/_templates/agents/architect/config.yaml +38 -0
- vstack/_templates/agents/architect/template.md +84 -0
- vstack/_templates/agents/designer/config.yaml +36 -0
- vstack/_templates/agents/designer/template.md +99 -0
- vstack/_templates/agents/engineer/config.yaml +36 -0
- vstack/_templates/agents/engineer/template.md +88 -0
- vstack/_templates/agents/product/config.yaml +37 -0
- vstack/_templates/agents/product/template.md +87 -0
- vstack/_templates/agents/release/config.yaml +35 -0
- vstack/_templates/agents/release/template.md +86 -0
- vstack/_templates/agents/tester/config.yaml +41 -0
- vstack/_templates/agents/tester/template.md +90 -0
- vstack/_templates/instructions/git/config.yaml +4 -0
- vstack/_templates/instructions/git/template.md +36 -0
- vstack/_templates/instructions/python/config.yaml +4 -0
- vstack/_templates/instructions/python/template.md +37 -0
- vstack/_templates/prompts/code-review/config.yaml +10 -0
- vstack/_templates/prompts/code-review/template.md +39 -0
- vstack/_templates/skills/_partials/base-branch.md +8 -0
- vstack/_templates/skills/_partials/observability-checklist.md +36 -0
- vstack/_templates/skills/_partials/run-tests.md +22 -0
- vstack/_templates/skills/_partials/skill-context.md +21 -0
- vstack/_templates/skills/adr/config.yaml +17 -0
- vstack/_templates/skills/adr/template.md +167 -0
- vstack/_templates/skills/analyse/config.yaml +16 -0
- vstack/_templates/skills/analyse/template.md +188 -0
- vstack/_templates/skills/architecture/config.yaml +18 -0
- vstack/_templates/skills/architecture/template.md +213 -0
- vstack/_templates/skills/cicd/config.yaml +16 -0
- vstack/_templates/skills/cicd/template.md +169 -0
- vstack/_templates/skills/code-review/config.yaml +16 -0
- vstack/_templates/skills/code-review/template.md +180 -0
- vstack/_templates/skills/concise/config.yaml +16 -0
- vstack/_templates/skills/concise/template.md +128 -0
- vstack/_templates/skills/consult/config.yaml +18 -0
- vstack/_templates/skills/consult/template.md +195 -0
- vstack/_templates/skills/container/config.yaml +17 -0
- vstack/_templates/skills/container/template.md +122 -0
- vstack/_templates/skills/debug/config.yaml +16 -0
- vstack/_templates/skills/debug/template.md +247 -0
- vstack/_templates/skills/dependency/config.yaml +18 -0
- vstack/_templates/skills/dependency/template.md +293 -0
- vstack/_templates/skills/design/config.yaml +16 -0
- vstack/_templates/skills/design/template.md +231 -0
- vstack/_templates/skills/docs/config.yaml +17 -0
- vstack/_templates/skills/docs/template.md +128 -0
- vstack/_templates/skills/explore/config.yaml +17 -0
- vstack/_templates/skills/explore/template.md +188 -0
- vstack/_templates/skills/guardrails/config.yaml +16 -0
- vstack/_templates/skills/guardrails/template.md +45 -0
- vstack/_templates/skills/incident/config.yaml +17 -0
- vstack/_templates/skills/incident/template.md +293 -0
- vstack/_templates/skills/inspect/config.yaml +16 -0
- vstack/_templates/skills/inspect/template.md +105 -0
- vstack/_templates/skills/migrate/config.yaml +17 -0
- vstack/_templates/skills/migrate/template.md +298 -0
- vstack/_templates/skills/onboard/config.yaml +18 -0
- vstack/_templates/skills/onboard/template.md +289 -0
- vstack/_templates/skills/openapi/config.yaml +17 -0
- vstack/_templates/skills/openapi/template.md +382 -0
- vstack/_templates/skills/performance/config.yaml +15 -0
- vstack/_templates/skills/performance/template.md +198 -0
- vstack/_templates/skills/pr/config.yaml +15 -0
- vstack/_templates/skills/pr/template.md +108 -0
- vstack/_templates/skills/refactor/config.yaml +18 -0
- vstack/_templates/skills/refactor/template.md +283 -0
- vstack/_templates/skills/release-notes/config.yaml +16 -0
- vstack/_templates/skills/release-notes/template.md +127 -0
- vstack/_templates/skills/requirements/config.yaml +17 -0
- vstack/_templates/skills/requirements/template.md +187 -0
- vstack/_templates/skills/security/config.yaml +17 -0
- vstack/_templates/skills/security/template.md +256 -0
- vstack/_templates/skills/verify/config.yaml +17 -0
- vstack/_templates/skills/verify/template.md +201 -0
- vstack/_templates/skills/vision/config.yaml +19 -0
- vstack/_templates/skills/vision/template.md +169 -0
- vstack/agents/__init__.py +5 -0
- vstack/agents/config.py +67 -0
- vstack/agents/constants.py +14 -0
- vstack/agents/generator.py +20 -0
- vstack/artifacts/__init__.py +17 -0
- vstack/artifacts/config.py +111 -0
- vstack/artifacts/constants.py +6 -0
- vstack/artifacts/generator.py +406 -0
- vstack/artifacts/models.py +55 -0
- vstack/artifacts/protocol.py +50 -0
- vstack/cli/__init__.py +3 -0
- vstack/cli/commands.py +596 -0
- vstack/cli/constants.py +33 -0
- vstack/cli/manifest.py +166 -0
- vstack/cli/parser.py +156 -0
- vstack/constants.py +84 -0
- vstack/frontmatter/__init__.py +8 -0
- vstack/frontmatter/parser.py +272 -0
- vstack/frontmatter/schema.py +142 -0
- vstack/frontmatter/serializer.py +208 -0
- vstack/instructions/__init__.py +5 -0
- vstack/instructions/config.py +21 -0
- vstack/instructions/constants.py +9 -0
- vstack/instructions/generator.py +13 -0
- vstack/main.py +71 -0
- vstack/models.py +35 -0
- vstack/prompts/__init__.py +5 -0
- vstack/prompts/config.py +21 -0
- vstack/prompts/constants.py +9 -0
- vstack/prompts/generator.py +13 -0
- vstack/skills/__init__.py +5 -0
- vstack/skills/config.py +58 -0
- vstack/skills/constants.py +17 -0
- vstack/skills/generator.py +20 -0
- vstack/skills/models.py +15 -0
- vstack-0.0.0.dist-info/METADATA +725 -0
- vstack-0.0.0.dist-info/RECORD +119 -0
- vstack-0.0.0.dist-info/WHEEL +4 -0
- vstack-0.0.0.dist-info/entry_points.txt +3 -0
- vstack-0.0.0.dist-info/licenses/LICENSE +21 -0
vstack/cli/commands.py
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""All vstack CLI commands as instance methods on :class:`CommandLineInterface`.
|
|
2
|
+
|
|
3
|
+
The CLI is entirely type-generic: it iterates over
|
|
4
|
+
:data:`~vstack.artifacts.type_config.KNOWN_TYPES` for validate, install, and
|
|
5
|
+
verify instead of hard-coding skill-specific and agent-specific logic.
|
|
6
|
+
|
|
7
|
+
All commands that touch the install root accept a single ``install_dir``
|
|
8
|
+
(either workspace ``.github/`` or the VS Code user profile directory)
|
|
9
|
+
rather than separate ``skills_dir`` / ``agents_dir`` parameters — per-type
|
|
10
|
+
sub-directories are derived from
|
|
11
|
+
:attr:`~vstack.artifacts.type_config.ArtifactTypeConfig.output_subdir`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from vstack.agents.config import AGENT_TYPE
|
|
22
|
+
from vstack.artifacts.generator import GenericArtifactGenerator
|
|
23
|
+
from vstack.cli.constants import EXPECTED_CANONICAL_NAMES
|
|
24
|
+
from vstack.cli.manifest import ArtifactEntry, Manifest, ManifestFile
|
|
25
|
+
from vstack.constants import VERSION
|
|
26
|
+
from vstack.instructions.config import INSTRUCTION_TYPE
|
|
27
|
+
from vstack.models import CheckMessage, ValidationResult
|
|
28
|
+
from vstack.prompts.config import PROMPT_TYPE
|
|
29
|
+
from vstack.skills.config import SKILL_TYPE
|
|
30
|
+
|
|
31
|
+
KNOWN_TYPES = [SKILL_TYPE, AGENT_TYPE, INSTRUCTION_TYPE, PROMPT_TYPE]
|
|
32
|
+
|
|
33
|
+
# Names that must exist for the "skill" artifact type (used in verify --source).
|
|
34
|
+
_EXPECTED_INPUT_NAMES: dict[str, list[str]] = {
|
|
35
|
+
"skill": EXPECTED_CANONICAL_NAMES,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Colors:
|
|
40
|
+
"""ANSI colour codes used in install output."""
|
|
41
|
+
|
|
42
|
+
GREEN = "\033[32m"
|
|
43
|
+
YELLOW = "\033[33m"
|
|
44
|
+
CYAN = "\033[36m"
|
|
45
|
+
BOLD = "\033[1m"
|
|
46
|
+
DIM = "\033[2m"
|
|
47
|
+
RESET = "\033[0m"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _version_gt(new: str, existing: str) -> bool:
|
|
51
|
+
"""Return True when *new* semver string is strictly greater than *existing*."""
|
|
52
|
+
|
|
53
|
+
def _t(v: str) -> tuple[int, ...]:
|
|
54
|
+
"""Convert a dotted version string to an integer tuple for comparison.
|
|
55
|
+
|
|
56
|
+
Invalid or non-semver-like inputs collapse to ``(0,)`` so the caller
|
|
57
|
+
can treat them as older than any valid semantic version.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
return tuple(int(x) for x in v.split("."))
|
|
61
|
+
except (ValueError, AttributeError):
|
|
62
|
+
return (0,)
|
|
63
|
+
|
|
64
|
+
return _t(new) > _t(existing)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CommandLineInterface:
|
|
68
|
+
"""Coordinate validation, installation, verification, and uninstall flows.
|
|
69
|
+
|
|
70
|
+
The class wires artifact-type generators into user-facing CLI operations,
|
|
71
|
+
while keeping per-type behavior in ``ArtifactTypeConfig`` definitions.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, templates_root: Path) -> None:
|
|
75
|
+
"""Create generators for all known artifact families.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
templates_root: Root directory containing the source templates.
|
|
79
|
+
"""
|
|
80
|
+
self.root = templates_root
|
|
81
|
+
self._generators: list[GenericArtifactGenerator] = [
|
|
82
|
+
GenericArtifactGenerator(tc, templates_root) for tc in KNOWN_TYPES
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
# ── Internal helpers ──────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def _label(self, path: Path) -> str:
|
|
88
|
+
"""Return *path* relative to template root when possible.
|
|
89
|
+
|
|
90
|
+
This keeps CLI output concise while still handling absolute paths that
|
|
91
|
+
are outside the template tree.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
return str(path.relative_to(self.root))
|
|
95
|
+
except ValueError:
|
|
96
|
+
return str(path)
|
|
97
|
+
|
|
98
|
+
def _manifest(self, install_dir: Path) -> ManifestFile:
|
|
99
|
+
"""Build the manifest accessor for a given install directory."""
|
|
100
|
+
return ManifestFile(parent_dir=install_dir)
|
|
101
|
+
|
|
102
|
+
def _gen_for(self, type_name: str) -> GenericArtifactGenerator | None:
|
|
103
|
+
"""Return the generator for *type_name*, or ``None`` when unknown."""
|
|
104
|
+
return next((g for g in self._generators if g.config.type_name == type_name), None)
|
|
105
|
+
|
|
106
|
+
def _expected_output_names(
|
|
107
|
+
self,
|
|
108
|
+
gen: GenericArtifactGenerator,
|
|
109
|
+
manifest_data: Manifest | None,
|
|
110
|
+
) -> list[str] | None:
|
|
111
|
+
"""Resolve expected output names for verify output checks."""
|
|
112
|
+
if manifest_data:
|
|
113
|
+
return manifest_data.names_for(gen.config.manifest_key)
|
|
114
|
+
return _EXPECTED_INPUT_NAMES.get(gen.config.type_name)
|
|
115
|
+
|
|
116
|
+
def _expected_manifest_metadata(
|
|
117
|
+
self,
|
|
118
|
+
gen: GenericArtifactGenerator,
|
|
119
|
+
manifest_data: Manifest,
|
|
120
|
+
entry: ArtifactEntry,
|
|
121
|
+
) -> dict[str, str]:
|
|
122
|
+
"""Build expected metadata values for one manifest-tracked artifact."""
|
|
123
|
+
expected_meta = {
|
|
124
|
+
"generator": "vstack",
|
|
125
|
+
"vstack_version": manifest_data.vstack_version,
|
|
126
|
+
"artifact_type": gen.config.type_name,
|
|
127
|
+
"artifact_name": entry.name,
|
|
128
|
+
}
|
|
129
|
+
if entry.version is not None:
|
|
130
|
+
expected_meta["artifact_version"] = entry.version
|
|
131
|
+
return expected_meta
|
|
132
|
+
|
|
133
|
+
def _verify_manifest_metadata_entry(
|
|
134
|
+
self,
|
|
135
|
+
gen: GenericArtifactGenerator,
|
|
136
|
+
manifest_data: Manifest,
|
|
137
|
+
entry: ArtifactEntry,
|
|
138
|
+
artifact_path: Path,
|
|
139
|
+
) -> ValidationResult:
|
|
140
|
+
"""Verify manifest-linked metadata for a single artifact file."""
|
|
141
|
+
result = ValidationResult()
|
|
142
|
+
content = artifact_path.read_text(encoding="utf-8")
|
|
143
|
+
metadata = GenericArtifactGenerator.parse_generation_metadata(content)
|
|
144
|
+
rel_path = self._label(artifact_path)
|
|
145
|
+
|
|
146
|
+
if metadata is None:
|
|
147
|
+
if "AUTO-GENERATED" not in content:
|
|
148
|
+
result.messages.append(
|
|
149
|
+
CheckMessage(
|
|
150
|
+
"fail",
|
|
151
|
+
f"{rel_path}: missing VSTACK-META and missing AUTO-GENERATED footer",
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
result.messages.append(
|
|
157
|
+
CheckMessage(
|
|
158
|
+
"pass",
|
|
159
|
+
f"{rel_path}: missing VSTACK-META footer; "
|
|
160
|
+
"treating manifest entry as source of truth",
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
result.messages.append(
|
|
164
|
+
CheckMessage(
|
|
165
|
+
"pass",
|
|
166
|
+
f"{rel_path}: legacy artifact accepted from manifest tracking",
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
for key, expected_value in self._expected_manifest_metadata(
|
|
172
|
+
gen, manifest_data, entry
|
|
173
|
+
).items():
|
|
174
|
+
actual_value = metadata.get(key)
|
|
175
|
+
if actual_value == expected_value:
|
|
176
|
+
result.messages.append(CheckMessage("pass", f"{rel_path}: {key} matches manifest"))
|
|
177
|
+
else:
|
|
178
|
+
result.messages.append(
|
|
179
|
+
CheckMessage(
|
|
180
|
+
"fail",
|
|
181
|
+
f"{rel_path}: {key} mismatch "
|
|
182
|
+
f"(expected '{expected_value}', got '{actual_value}')",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
def _verify_manifest_metadata(
|
|
189
|
+
self,
|
|
190
|
+
gen: GenericArtifactGenerator,
|
|
191
|
+
manifest_data: Manifest,
|
|
192
|
+
install_dir: Path,
|
|
193
|
+
) -> ValidationResult | None:
|
|
194
|
+
"""Verify footer metadata for all manifest-tracked artifacts of one type."""
|
|
195
|
+
manifest_entries = manifest_data.entries_for(gen.config.manifest_key)
|
|
196
|
+
if not manifest_entries:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
result = ValidationResult()
|
|
200
|
+
for entry in manifest_entries:
|
|
201
|
+
artifact_path = install_dir / entry.file
|
|
202
|
+
if not artifact_path.exists():
|
|
203
|
+
continue
|
|
204
|
+
entry_result = self._verify_manifest_metadata_entry(
|
|
205
|
+
gen,
|
|
206
|
+
manifest_data,
|
|
207
|
+
entry,
|
|
208
|
+
artifact_path,
|
|
209
|
+
)
|
|
210
|
+
result.messages.extend(entry_result.messages)
|
|
211
|
+
|
|
212
|
+
return result if result.messages else None
|
|
213
|
+
|
|
214
|
+
def _existing_versions_for_install(
|
|
215
|
+
self,
|
|
216
|
+
gens: list[GenericArtifactGenerator],
|
|
217
|
+
existing_manifest: Manifest | None,
|
|
218
|
+
) -> dict[str, str | None]:
|
|
219
|
+
"""Build existing-version lookup for selected generators."""
|
|
220
|
+
if existing_manifest is None:
|
|
221
|
+
return {}
|
|
222
|
+
|
|
223
|
+
existing_versions: dict[str, str | None] = {}
|
|
224
|
+
for gen in gens:
|
|
225
|
+
for entry in existing_manifest.entries_for(gen.config.manifest_key):
|
|
226
|
+
key = f"{gen.config.type_name}/{entry.name}"
|
|
227
|
+
existing_versions[key] = entry.version
|
|
228
|
+
return existing_versions
|
|
229
|
+
|
|
230
|
+
def _preserved_manifest_entries(
|
|
231
|
+
self,
|
|
232
|
+
existing_manifest: Manifest | None,
|
|
233
|
+
selected_manifest_keys: set[str],
|
|
234
|
+
) -> dict[str, list[ArtifactEntry]]:
|
|
235
|
+
"""Keep existing manifest entries for artifact families not in this run."""
|
|
236
|
+
if existing_manifest is None:
|
|
237
|
+
return {}
|
|
238
|
+
|
|
239
|
+
preserved: dict[str, list[ArtifactEntry]] = {}
|
|
240
|
+
for manifest_key, entries in existing_manifest.artifacts.items():
|
|
241
|
+
if manifest_key not in selected_manifest_keys:
|
|
242
|
+
preserved[manifest_key] = list(entries)
|
|
243
|
+
return preserved
|
|
244
|
+
|
|
245
|
+
def _install_action(
|
|
246
|
+
self,
|
|
247
|
+
*,
|
|
248
|
+
force: bool,
|
|
249
|
+
update: bool,
|
|
250
|
+
out_file: Path,
|
|
251
|
+
existing_version: str | None,
|
|
252
|
+
new_version: str,
|
|
253
|
+
) -> str:
|
|
254
|
+
"""Return install action: install, skip, or update."""
|
|
255
|
+
if force:
|
|
256
|
+
return "install"
|
|
257
|
+
if out_file.exists() and existing_version is not None:
|
|
258
|
+
if update:
|
|
259
|
+
return "update" if _version_gt(new_version, existing_version) else "skip"
|
|
260
|
+
return "skip"
|
|
261
|
+
return "install"
|
|
262
|
+
|
|
263
|
+
def _print_install_action(
|
|
264
|
+
self,
|
|
265
|
+
*,
|
|
266
|
+
colors: type[_Colors],
|
|
267
|
+
prefix: str,
|
|
268
|
+
rel: str,
|
|
269
|
+
action: str,
|
|
270
|
+
existing_version: str | None,
|
|
271
|
+
new_version: str,
|
|
272
|
+
out_file: Path,
|
|
273
|
+
force: bool,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Print install/update/skip line for one artifact."""
|
|
276
|
+
if action == "skip":
|
|
277
|
+
print(
|
|
278
|
+
f" {colors.YELLOW}↷{colors.RESET} {rel}"
|
|
279
|
+
f" {colors.DIM}skipped — already v{existing_version}{colors.RESET}"
|
|
280
|
+
)
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
if action == "update":
|
|
284
|
+
print(
|
|
285
|
+
f" {prefix}{colors.CYAN}↑{colors.RESET} "
|
|
286
|
+
f"{colors.BOLD}{rel}{colors.RESET}"
|
|
287
|
+
f" v{existing_version} → {colors.GREEN}v{new_version}{colors.RESET}"
|
|
288
|
+
)
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
tag = "(forced) " if force and out_file.exists() else ""
|
|
292
|
+
print(
|
|
293
|
+
f" {prefix}{colors.GREEN}✓{colors.RESET} "
|
|
294
|
+
f"{colors.BOLD}{rel}{colors.RESET}"
|
|
295
|
+
f" {colors.DIM}{tag}{colors.RESET}{colors.GREEN}v{new_version}{colors.RESET}"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _record_manifest_entry(
|
|
299
|
+
self,
|
|
300
|
+
*,
|
|
301
|
+
new_entries: dict[str, list[ArtifactEntry]],
|
|
302
|
+
gen: GenericArtifactGenerator,
|
|
303
|
+
artifact_name: str,
|
|
304
|
+
version: str,
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Append one installed artifact entry to the in-memory manifest payload."""
|
|
307
|
+
new_entries.setdefault(gen.config.manifest_key, []).append(
|
|
308
|
+
ArtifactEntry(
|
|
309
|
+
name=artifact_name,
|
|
310
|
+
file=gen.install_relative_path(artifact_name),
|
|
311
|
+
version=version,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# ── validate ──────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
def validate(self, only: list[str] | None = None) -> int:
|
|
318
|
+
"""Render all templates in memory; report unresolved tokens. No files written."""
|
|
319
|
+
gens = [g for g in self._generators if only is None or g.config.type_name in only]
|
|
320
|
+
all_artifacts: dict[str, list] = {}
|
|
321
|
+
total_partials = 0
|
|
322
|
+
for gen in gens:
|
|
323
|
+
artifacts = gen.render_all()
|
|
324
|
+
all_artifacts[gen.config.type_name] = artifacts
|
|
325
|
+
total_partials += len(gen.load_partials())
|
|
326
|
+
|
|
327
|
+
if not any(all_artifacts.values()):
|
|
328
|
+
print("ERROR: No templates found", file=sys.stderr)
|
|
329
|
+
return 1
|
|
330
|
+
|
|
331
|
+
errors: list = []
|
|
332
|
+
for type_name, artifacts in all_artifacts.items():
|
|
333
|
+
type_gen = self._gen_for(type_name)
|
|
334
|
+
if type_gen is None:
|
|
335
|
+
continue
|
|
336
|
+
print(f"\n{type_name.capitalize()} ({len(artifacts)}):")
|
|
337
|
+
for a in artifacts:
|
|
338
|
+
suffix = f" ⚠ unresolved: {a.unresolved}" if a.unresolved else ""
|
|
339
|
+
print(f" {type_gen.output_path(a.name)}{suffix}")
|
|
340
|
+
errors.extend(a for a in artifacts if a.unresolved)
|
|
341
|
+
|
|
342
|
+
total = sum(len(v) for v in all_artifacts.values())
|
|
343
|
+
if errors:
|
|
344
|
+
print(
|
|
345
|
+
f"\nERROR: {len(errors)} template(s) have unresolved placeholders",
|
|
346
|
+
file=sys.stderr,
|
|
347
|
+
)
|
|
348
|
+
return 1
|
|
349
|
+
print(f"\nOK: {total} artifact(s), {total_partials} partial(s)")
|
|
350
|
+
return 0
|
|
351
|
+
|
|
352
|
+
# ── install ───────────────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
def install(
|
|
355
|
+
self,
|
|
356
|
+
install_dir: Path,
|
|
357
|
+
*,
|
|
358
|
+
only: list[str] | None = None,
|
|
359
|
+
force: bool = False,
|
|
360
|
+
update: bool = False,
|
|
361
|
+
dry_run: bool = False,
|
|
362
|
+
) -> int:
|
|
363
|
+
"""Generate and install artifacts into install_dir.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
install_dir: Workspace ``.github/`` or VS Code user profile install root.
|
|
367
|
+
only: Optional list of type names to restrict installation.
|
|
368
|
+
force: Overwrite existing artifacts unconditionally.
|
|
369
|
+
update: Install only when a newer version is available.
|
|
370
|
+
dry_run: Print what would happen without writing any files.
|
|
371
|
+
"""
|
|
372
|
+
_C = _Colors
|
|
373
|
+
|
|
374
|
+
gens = [g for g in self._generators if only is None or g.config.type_name in only]
|
|
375
|
+
|
|
376
|
+
# Read existing manifest once — used to look up installed versions.
|
|
377
|
+
mf = self._manifest(install_dir)
|
|
378
|
+
existing_manifest = mf.read()
|
|
379
|
+
selected_manifest_keys = {gen.config.manifest_key for gen in gens}
|
|
380
|
+
existing_versions = self._existing_versions_for_install(gens, existing_manifest)
|
|
381
|
+
|
|
382
|
+
prefix = f"{_C.DIM}[dry-run]{_C.RESET} " if dry_run else ""
|
|
383
|
+
all_ok = True
|
|
384
|
+
new_entries = self._preserved_manifest_entries(existing_manifest, selected_manifest_keys)
|
|
385
|
+
|
|
386
|
+
for gen in gens:
|
|
387
|
+
out_dir = install_dir / gen.config.output_subdir
|
|
388
|
+
artifacts = gen.render_all()
|
|
389
|
+
|
|
390
|
+
for artifact in artifacts:
|
|
391
|
+
out_file = out_dir / gen.output_path(artifact.name)
|
|
392
|
+
new_version = (artifact.frontmatter or {}).get("version") or VERSION
|
|
393
|
+
key = f"{gen.config.type_name}/{artifact.name}"
|
|
394
|
+
existing_version = existing_versions.get(key)
|
|
395
|
+
rel = self._label(out_file)
|
|
396
|
+
|
|
397
|
+
# Unresolved placeholder warnings always shown.
|
|
398
|
+
if artifact.unresolved:
|
|
399
|
+
print(
|
|
400
|
+
f" {_C.YELLOW}⚠{_C.RESET} {rel} unresolved: {artifact.unresolved}",
|
|
401
|
+
file=sys.stderr,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
action = self._install_action(
|
|
405
|
+
force=force,
|
|
406
|
+
update=update,
|
|
407
|
+
out_file=out_file,
|
|
408
|
+
existing_version=existing_version,
|
|
409
|
+
new_version=new_version,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
if action == "skip" and out_file.exists():
|
|
413
|
+
existing_meta = GenericArtifactGenerator.parse_generation_metadata(
|
|
414
|
+
out_file.read_text(encoding="utf-8")
|
|
415
|
+
)
|
|
416
|
+
if existing_meta is not None and existing_meta.get("vstack_version") != VERSION:
|
|
417
|
+
action = "install"
|
|
418
|
+
|
|
419
|
+
self._print_install_action(
|
|
420
|
+
colors=_C,
|
|
421
|
+
prefix=prefix,
|
|
422
|
+
rel=rel,
|
|
423
|
+
action=action,
|
|
424
|
+
existing_version=existing_version,
|
|
425
|
+
new_version=new_version,
|
|
426
|
+
out_file=out_file,
|
|
427
|
+
force=force,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if not dry_run and action != "skip":
|
|
431
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
432
|
+
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
433
|
+
out_file.write_text(artifact.content, encoding="utf-8")
|
|
434
|
+
|
|
435
|
+
self._record_manifest_entry(
|
|
436
|
+
new_entries=new_entries,
|
|
437
|
+
gen=gen,
|
|
438
|
+
artifact_name=artifact.name,
|
|
439
|
+
version=new_version,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Verify source for unresolvable issues.
|
|
443
|
+
vr = gen.verify_input()
|
|
444
|
+
for msg in vr.messages:
|
|
445
|
+
if msg.level == "fail":
|
|
446
|
+
print(f" ERROR [{gen.config.type_name}]: {msg.message}", file=sys.stderr)
|
|
447
|
+
all_ok = False
|
|
448
|
+
|
|
449
|
+
if not dry_run:
|
|
450
|
+
manifest = Manifest(
|
|
451
|
+
vstack_version=VERSION,
|
|
452
|
+
installed_at=datetime.datetime.now(datetime.UTC).isoformat(),
|
|
453
|
+
artifacts=new_entries,
|
|
454
|
+
)
|
|
455
|
+
mf.write(manifest)
|
|
456
|
+
print(f" {_C.DIM}wrote {self._label(mf.path)}{_C.RESET}")
|
|
457
|
+
|
|
458
|
+
return 0 if all_ok else 1
|
|
459
|
+
|
|
460
|
+
# ── verify ────────────────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
def verify(
|
|
463
|
+
self,
|
|
464
|
+
install_dir: Path | None = None,
|
|
465
|
+
*,
|
|
466
|
+
source: bool = True,
|
|
467
|
+
output: bool = True,
|
|
468
|
+
only: list[str] | None = None,
|
|
469
|
+
) -> int:
|
|
470
|
+
"""Check source templates and/or installed output.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
install_dir: Workspace ``.github/`` or VS Code user profile install root.
|
|
474
|
+
source: Verify source templates (metadata, tokens, structure).
|
|
475
|
+
output: Verify installed output files against the manifest.
|
|
476
|
+
only: Optional list of type names to restrict verification.
|
|
477
|
+
"""
|
|
478
|
+
results: list[ValidationResult] = []
|
|
479
|
+
section_count = sum([source, output])
|
|
480
|
+
step = 0
|
|
481
|
+
gens = [g for g in self._generators if only is None or g.config.type_name in only]
|
|
482
|
+
|
|
483
|
+
def _header(label: str) -> None:
|
|
484
|
+
"""Print a numbered section header for verify progress output."""
|
|
485
|
+
nonlocal step
|
|
486
|
+
step += 1
|
|
487
|
+
print(f"[{step}/{section_count}] {label}")
|
|
488
|
+
|
|
489
|
+
def _print_result(result: ValidationResult) -> None:
|
|
490
|
+
"""Render one validation result block and accumulate totals."""
|
|
491
|
+
for msg in result.messages:
|
|
492
|
+
prefix = "✓" if msg.level == "pass" else "✗"
|
|
493
|
+
print(f" {prefix} {msg.message}")
|
|
494
|
+
if result.failures == 0:
|
|
495
|
+
print(f" ✓ {result.passes} check(s) passed")
|
|
496
|
+
results.append(result)
|
|
497
|
+
|
|
498
|
+
if source:
|
|
499
|
+
_header("checking source templates")
|
|
500
|
+
for gen in gens:
|
|
501
|
+
expected = _EXPECTED_INPUT_NAMES.get(gen.config.type_name)
|
|
502
|
+
vr = gen.verify_input(expected)
|
|
503
|
+
if vr.messages:
|
|
504
|
+
_print_result(vr)
|
|
505
|
+
else:
|
|
506
|
+
print(f" (no {gen.config.type_name} templates found, skipping)")
|
|
507
|
+
|
|
508
|
+
if output:
|
|
509
|
+
if install_dir is None:
|
|
510
|
+
print("ERROR: install_dir required for output checks", file=sys.stderr)
|
|
511
|
+
return 1
|
|
512
|
+
manifest_data = self._manifest(install_dir).read()
|
|
513
|
+
|
|
514
|
+
for gen in gens:
|
|
515
|
+
out_dir = install_dir / gen.config.output_subdir
|
|
516
|
+
_header(f"checking installed {gen.config.type_name} ({self._label(out_dir)}/)")
|
|
517
|
+
if not out_dir.exists():
|
|
518
|
+
_print_result(
|
|
519
|
+
ValidationResult(
|
|
520
|
+
messages=[
|
|
521
|
+
CheckMessage(
|
|
522
|
+
level="fail",
|
|
523
|
+
message=f"{self._label(out_dir)}/ not found — run: vstack install",
|
|
524
|
+
)
|
|
525
|
+
]
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
else:
|
|
529
|
+
expected = self._expected_output_names(gen, manifest_data)
|
|
530
|
+
_print_result(gen.verify_output(out_dir, expected))
|
|
531
|
+
|
|
532
|
+
if manifest_data:
|
|
533
|
+
metadata_result = self._verify_manifest_metadata(
|
|
534
|
+
gen, manifest_data, install_dir
|
|
535
|
+
)
|
|
536
|
+
if metadata_result:
|
|
537
|
+
_print_result(metadata_result)
|
|
538
|
+
|
|
539
|
+
print()
|
|
540
|
+
total_failures = sum(r.failures for r in results)
|
|
541
|
+
if total_failures:
|
|
542
|
+
print(f"FAILED: {total_failures} check(s) failed")
|
|
543
|
+
return 1
|
|
544
|
+
print("All checks passed.")
|
|
545
|
+
return 0
|
|
546
|
+
|
|
547
|
+
# ── uninstall ─────────────────────────────────────────────────────────────
|
|
548
|
+
|
|
549
|
+
def uninstall(self, install_dir: Path) -> int:
|
|
550
|
+
"""Remove only vstack-managed files. User files are not touched."""
|
|
551
|
+
removed: list[str] = []
|
|
552
|
+
manifest_data = self._manifest(install_dir).read()
|
|
553
|
+
|
|
554
|
+
for gen in self._generators:
|
|
555
|
+
out_dir = install_dir / gen.config.output_subdir
|
|
556
|
+
entries = manifest_data.entries_for(gen.config.type_name) if manifest_data else []
|
|
557
|
+
|
|
558
|
+
if not entries:
|
|
559
|
+
# No manifest entries: fall back to known names from templates
|
|
560
|
+
fallback = _EXPECTED_INPUT_NAMES.get(gen.config.type_name)
|
|
561
|
+
if fallback:
|
|
562
|
+
entries = [
|
|
563
|
+
ArtifactEntry(
|
|
564
|
+
name=n,
|
|
565
|
+
file=gen.config.output_subdir + "/" + gen.output_path(n),
|
|
566
|
+
)
|
|
567
|
+
for n in fallback
|
|
568
|
+
]
|
|
569
|
+
|
|
570
|
+
for entry in entries:
|
|
571
|
+
full_path = install_dir / entry.file
|
|
572
|
+
if gen.config.artifact_is_dir:
|
|
573
|
+
artifact_dir = full_path.parent
|
|
574
|
+
if artifact_dir.exists() and artifact_dir != out_dir:
|
|
575
|
+
shutil.rmtree(artifact_dir)
|
|
576
|
+
removed.append(self._label(artifact_dir))
|
|
577
|
+
else:
|
|
578
|
+
if full_path.exists():
|
|
579
|
+
full_path.unlink()
|
|
580
|
+
removed.append(self._label(full_path))
|
|
581
|
+
|
|
582
|
+
if out_dir.exists() and not any(out_dir.iterdir()):
|
|
583
|
+
out_dir.rmdir()
|
|
584
|
+
removed.append(self._label(out_dir) + "/")
|
|
585
|
+
|
|
586
|
+
mf = self._manifest(install_dir)
|
|
587
|
+
if mf.exists():
|
|
588
|
+
mf.path.unlink()
|
|
589
|
+
removed.append(self._label(mf.path))
|
|
590
|
+
|
|
591
|
+
if removed:
|
|
592
|
+
for p in removed:
|
|
593
|
+
print(f" removed {p}")
|
|
594
|
+
else:
|
|
595
|
+
print("Nothing to remove.")
|
|
596
|
+
return 0
|
vstack/cli/constants.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Canonical CLI artifact names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
EXPECTED_CANONICAL_NAMES = [
|
|
6
|
+
"vision",
|
|
7
|
+
"architecture",
|
|
8
|
+
"requirements",
|
|
9
|
+
"adr",
|
|
10
|
+
"design",
|
|
11
|
+
"consult",
|
|
12
|
+
"concise",
|
|
13
|
+
"code-review",
|
|
14
|
+
"release-notes",
|
|
15
|
+
"pr",
|
|
16
|
+
"verify",
|
|
17
|
+
"inspect",
|
|
18
|
+
"security",
|
|
19
|
+
"debug",
|
|
20
|
+
"performance",
|
|
21
|
+
"analyse",
|
|
22
|
+
"explore",
|
|
23
|
+
"docs",
|
|
24
|
+
"guardrails",
|
|
25
|
+
"container",
|
|
26
|
+
"cicd",
|
|
27
|
+
"migrate",
|
|
28
|
+
"openapi",
|
|
29
|
+
"refactor",
|
|
30
|
+
"onboard",
|
|
31
|
+
"dependency",
|
|
32
|
+
"incident",
|
|
33
|
+
]
|