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.
Files changed (119) hide show
  1. vstack/__init__.py +5 -0
  2. vstack/__main__.py +5 -0
  3. vstack/_templates/agents/_partials/agent-skill-boundary.md +5 -0
  4. vstack/_templates/agents/architect/config.yaml +38 -0
  5. vstack/_templates/agents/architect/template.md +84 -0
  6. vstack/_templates/agents/designer/config.yaml +36 -0
  7. vstack/_templates/agents/designer/template.md +99 -0
  8. vstack/_templates/agents/engineer/config.yaml +36 -0
  9. vstack/_templates/agents/engineer/template.md +88 -0
  10. vstack/_templates/agents/product/config.yaml +37 -0
  11. vstack/_templates/agents/product/template.md +87 -0
  12. vstack/_templates/agents/release/config.yaml +35 -0
  13. vstack/_templates/agents/release/template.md +86 -0
  14. vstack/_templates/agents/tester/config.yaml +41 -0
  15. vstack/_templates/agents/tester/template.md +90 -0
  16. vstack/_templates/instructions/git/config.yaml +4 -0
  17. vstack/_templates/instructions/git/template.md +36 -0
  18. vstack/_templates/instructions/python/config.yaml +4 -0
  19. vstack/_templates/instructions/python/template.md +37 -0
  20. vstack/_templates/prompts/code-review/config.yaml +10 -0
  21. vstack/_templates/prompts/code-review/template.md +39 -0
  22. vstack/_templates/skills/_partials/base-branch.md +8 -0
  23. vstack/_templates/skills/_partials/observability-checklist.md +36 -0
  24. vstack/_templates/skills/_partials/run-tests.md +22 -0
  25. vstack/_templates/skills/_partials/skill-context.md +21 -0
  26. vstack/_templates/skills/adr/config.yaml +17 -0
  27. vstack/_templates/skills/adr/template.md +167 -0
  28. vstack/_templates/skills/analyse/config.yaml +16 -0
  29. vstack/_templates/skills/analyse/template.md +188 -0
  30. vstack/_templates/skills/architecture/config.yaml +18 -0
  31. vstack/_templates/skills/architecture/template.md +213 -0
  32. vstack/_templates/skills/cicd/config.yaml +16 -0
  33. vstack/_templates/skills/cicd/template.md +169 -0
  34. vstack/_templates/skills/code-review/config.yaml +16 -0
  35. vstack/_templates/skills/code-review/template.md +180 -0
  36. vstack/_templates/skills/concise/config.yaml +16 -0
  37. vstack/_templates/skills/concise/template.md +128 -0
  38. vstack/_templates/skills/consult/config.yaml +18 -0
  39. vstack/_templates/skills/consult/template.md +195 -0
  40. vstack/_templates/skills/container/config.yaml +17 -0
  41. vstack/_templates/skills/container/template.md +122 -0
  42. vstack/_templates/skills/debug/config.yaml +16 -0
  43. vstack/_templates/skills/debug/template.md +247 -0
  44. vstack/_templates/skills/dependency/config.yaml +18 -0
  45. vstack/_templates/skills/dependency/template.md +293 -0
  46. vstack/_templates/skills/design/config.yaml +16 -0
  47. vstack/_templates/skills/design/template.md +231 -0
  48. vstack/_templates/skills/docs/config.yaml +17 -0
  49. vstack/_templates/skills/docs/template.md +128 -0
  50. vstack/_templates/skills/explore/config.yaml +17 -0
  51. vstack/_templates/skills/explore/template.md +188 -0
  52. vstack/_templates/skills/guardrails/config.yaml +16 -0
  53. vstack/_templates/skills/guardrails/template.md +45 -0
  54. vstack/_templates/skills/incident/config.yaml +17 -0
  55. vstack/_templates/skills/incident/template.md +293 -0
  56. vstack/_templates/skills/inspect/config.yaml +16 -0
  57. vstack/_templates/skills/inspect/template.md +105 -0
  58. vstack/_templates/skills/migrate/config.yaml +17 -0
  59. vstack/_templates/skills/migrate/template.md +298 -0
  60. vstack/_templates/skills/onboard/config.yaml +18 -0
  61. vstack/_templates/skills/onboard/template.md +289 -0
  62. vstack/_templates/skills/openapi/config.yaml +17 -0
  63. vstack/_templates/skills/openapi/template.md +382 -0
  64. vstack/_templates/skills/performance/config.yaml +15 -0
  65. vstack/_templates/skills/performance/template.md +198 -0
  66. vstack/_templates/skills/pr/config.yaml +15 -0
  67. vstack/_templates/skills/pr/template.md +108 -0
  68. vstack/_templates/skills/refactor/config.yaml +18 -0
  69. vstack/_templates/skills/refactor/template.md +283 -0
  70. vstack/_templates/skills/release-notes/config.yaml +16 -0
  71. vstack/_templates/skills/release-notes/template.md +127 -0
  72. vstack/_templates/skills/requirements/config.yaml +17 -0
  73. vstack/_templates/skills/requirements/template.md +187 -0
  74. vstack/_templates/skills/security/config.yaml +17 -0
  75. vstack/_templates/skills/security/template.md +256 -0
  76. vstack/_templates/skills/verify/config.yaml +17 -0
  77. vstack/_templates/skills/verify/template.md +201 -0
  78. vstack/_templates/skills/vision/config.yaml +19 -0
  79. vstack/_templates/skills/vision/template.md +169 -0
  80. vstack/agents/__init__.py +5 -0
  81. vstack/agents/config.py +67 -0
  82. vstack/agents/constants.py +14 -0
  83. vstack/agents/generator.py +20 -0
  84. vstack/artifacts/__init__.py +17 -0
  85. vstack/artifacts/config.py +111 -0
  86. vstack/artifacts/constants.py +6 -0
  87. vstack/artifacts/generator.py +406 -0
  88. vstack/artifacts/models.py +55 -0
  89. vstack/artifacts/protocol.py +50 -0
  90. vstack/cli/__init__.py +3 -0
  91. vstack/cli/commands.py +596 -0
  92. vstack/cli/constants.py +33 -0
  93. vstack/cli/manifest.py +166 -0
  94. vstack/cli/parser.py +156 -0
  95. vstack/constants.py +84 -0
  96. vstack/frontmatter/__init__.py +8 -0
  97. vstack/frontmatter/parser.py +272 -0
  98. vstack/frontmatter/schema.py +142 -0
  99. vstack/frontmatter/serializer.py +208 -0
  100. vstack/instructions/__init__.py +5 -0
  101. vstack/instructions/config.py +21 -0
  102. vstack/instructions/constants.py +9 -0
  103. vstack/instructions/generator.py +13 -0
  104. vstack/main.py +71 -0
  105. vstack/models.py +35 -0
  106. vstack/prompts/__init__.py +5 -0
  107. vstack/prompts/config.py +21 -0
  108. vstack/prompts/constants.py +9 -0
  109. vstack/prompts/generator.py +13 -0
  110. vstack/skills/__init__.py +5 -0
  111. vstack/skills/config.py +58 -0
  112. vstack/skills/constants.py +17 -0
  113. vstack/skills/generator.py +20 -0
  114. vstack/skills/models.py +15 -0
  115. vstack-0.0.0.dist-info/METADATA +725 -0
  116. vstack-0.0.0.dist-info/RECORD +119 -0
  117. vstack-0.0.0.dist-info/WHEEL +4 -0
  118. vstack-0.0.0.dist-info/entry_points.txt +3 -0
  119. 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
@@ -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
+ ]