goodvibes-cli 1.6.1__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.
- goodvibes_cli/__init__.py +1 -0
- goodvibes_cli/__main__.py +15 -0
- goodvibes_cli/commands/__init__.py +0 -0
- goodvibes_cli/commands/doctor_cmd.py +110 -0
- goodvibes_cli/commands/init_cmd.py +102 -0
- goodvibes_cli/commands/upgrade_cmd.py +229 -0
- goodvibes_cli/main.py +32 -0
- goodvibes_cli/steps/__init__.py +0 -0
- goodvibes_cli/steps/configure_mcp.py +91 -0
- goodvibes_cli/steps/copy_templates.py +128 -0
- goodvibes_cli/steps/install_headroom.py +58 -0
- goodvibes_cli/templates/.amazonq/rules/goodvibes.md +28 -0
- goodvibes_cli/templates/.bolt/prompt +11 -0
- goodvibes_cli/templates/.claude/skills/cavecrew/README.md +41 -0
- goodvibes_cli/templates/.claude/skills/cavecrew/SKILL.md +82 -0
- goodvibes_cli/templates/.claude/skills/caveman/README.md +48 -0
- goodvibes_cli/templates/.claude/skills/caveman/SKILL.md +78 -0
- goodvibes_cli/templates/.claude/skills/caveman-commit/SKILL.md +65 -0
- goodvibes_cli/templates/.claude/skills/caveman-compress/SKILL.md +111 -0
- goodvibes_cli/templates/.claude/skills/caveman-help/SKILL.md +63 -0
- goodvibes_cli/templates/.claude/skills/caveman-review/SKILL.md +55 -0
- goodvibes_cli/templates/.claude/skills/caveman-stats/SKILL.md +10 -0
- goodvibes_cli/templates/.claude/skills/goodvibes-hygiene/SKILL.md +49 -0
- goodvibes_cli/templates/.clinerules/goodvibes.md +28 -0
- goodvibes_cli/templates/.continue/rules/goodvibes.md +28 -0
- goodvibes_cli/templates/.cursor/rules/goodvibes.mdc +28 -0
- goodvibes_cli/templates/.devin/rules/goodvibes.md +28 -0
- goodvibes_cli/templates/.github/ISSUE_TEMPLATE/bug_report.yml +54 -0
- goodvibes_cli/templates/.github/ISSUE_TEMPLATE/feature_request.yml +41 -0
- goodvibes_cli/templates/.github/PULL_REQUEST_TEMPLATE.md +13 -0
- goodvibes_cli/templates/.github/copilot-instructions.md +28 -0
- goodvibes_cli/templates/.github/dependabot.yml +20 -0
- goodvibes_cli/templates/.github/workflows/ci-both.yml +66 -0
- goodvibes_cli/templates/.github/workflows/ci-node.yml +36 -0
- goodvibes_cli/templates/.github/workflows/ci-python.yml +38 -0
- goodvibes_cli/templates/.github/workflows/dependency-review.yml +15 -0
- goodvibes_cli/templates/.github/workflows/security.yml +46 -0
- goodvibes_cli/templates/.kiro/steering/goodvibes.md +28 -0
- goodvibes_cli/templates/.windsurfrules +28 -0
- goodvibes_cli/templates/AGENTS.md +28 -0
- goodvibes_cli/templates/CHANGELOG.md +11 -0
- goodvibes_cli/templates/CLAUDE.md +131 -0
- goodvibes_cli/templates/CONTRIBUTING.md +55 -0
- goodvibes_cli/templates/GEMINI.md +28 -0
- goodvibes_cli/templates/JOURNAL.md +32 -0
- goodvibes_cli/templates/SECURITY.md +16 -0
- goodvibes_cli/templates/docs/getting-started.md +31 -0
- goodvibes_cli/templates/docs/onboarding.md +113 -0
- goodvibes_cli/templates/docs/platform-setup/base44.md +40 -0
- goodvibes_cli/templates/docs/platform-setup/bolt.md +22 -0
- goodvibes_cli/templates/docs/platform-setup/chatgpt.md +38 -0
- goodvibes_cli/templates/docs/platform-setup/cursor.md +23 -0
- goodvibes_cli/templates/docs/platform-setup/kiro.md +19 -0
- goodvibes_cli/templates/docs/platform-setup/replit.md +26 -0
- goodvibes_cli/templates/docs/platform-setup/windsurf.md +19 -0
- goodvibes_cli/templates/replit.md +32 -0
- goodvibes_cli/utils/__init__.py +0 -0
- goodvibes_cli/utils/detect_project_type.py +17 -0
- goodvibes_cli/utils/detect_python.py +37 -0
- goodvibes_cli/utils/sentinel_merge.py +80 -0
- goodvibes_cli-1.6.1.dist-info/METADATA +37 -0
- goodvibes_cli-1.6.1.dist-info/RECORD +64 -0
- goodvibes_cli-1.6.1.dist-info/WHEEL +4 -0
- goodvibes_cli-1.6.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
if sys.version_info < (3, 10):
|
|
4
|
+
print(
|
|
5
|
+
f"goodvibes requires Python 3.10 or higher. "
|
|
6
|
+
f"You have Python {sys.version_info.major}.{sys.version_info.minor}.",
|
|
7
|
+
file=sys.stderr,
|
|
8
|
+
)
|
|
9
|
+
sys.exit(1)
|
|
10
|
+
|
|
11
|
+
import typer # noqa: E402 — version guard must run before any import
|
|
12
|
+
from goodvibes_cli.main import app # noqa: E402
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""goodvibes doctor command — checks that goodvibes setup is complete."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
import pathlib
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
|
|
14
|
+
# ponytail: not imported from sentinel_merge — define locally to avoid coupling
|
|
15
|
+
SENTINEL_START = "<!-- goodvibes:start -->"
|
|
16
|
+
SENTINEL_END = "<!-- goodvibes:end -->"
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _installed_version() -> str:
|
|
22
|
+
try:
|
|
23
|
+
return importlib.metadata.version("goodvibes-cli")
|
|
24
|
+
except importlib.metadata.PackageNotFoundError:
|
|
25
|
+
return "unknown"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CheckResult:
|
|
30
|
+
label: str
|
|
31
|
+
passed: bool
|
|
32
|
+
remedy: str = field(default="")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_headroom() -> CheckResult:
|
|
36
|
+
present = shutil.which("headroom") is not None
|
|
37
|
+
return CheckResult(
|
|
38
|
+
label="headroom on PATH",
|
|
39
|
+
passed=present,
|
|
40
|
+
remedy="" if present else 'Run: uv tool install "headroom-ai[all]" (or re-run goodvibes init)',
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _check_git_config(key: str) -> CheckResult:
|
|
45
|
+
try:
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
["git", "config", key],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
check=True,
|
|
51
|
+
)
|
|
52
|
+
passed = bool(result.stdout.strip())
|
|
53
|
+
return CheckResult(
|
|
54
|
+
label=f"git {key}",
|
|
55
|
+
passed=passed,
|
|
56
|
+
remedy="" if passed else f'Run: git config --global {key} "Your Value"',
|
|
57
|
+
)
|
|
58
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
59
|
+
return CheckResult(
|
|
60
|
+
label=f"git {key}",
|
|
61
|
+
passed=False,
|
|
62
|
+
remedy=f'Run: git config --global {key} "Your Value"',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _check_claude_md(cwd: pathlib.Path) -> CheckResult:
|
|
67
|
+
present = (cwd / "CLAUDE.md").exists()
|
|
68
|
+
return CheckResult(
|
|
69
|
+
label="CLAUDE.md present",
|
|
70
|
+
passed=present,
|
|
71
|
+
remedy="" if present else "Run: goodvibes init",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _check_sentinel(cwd: pathlib.Path) -> CheckResult:
|
|
76
|
+
path = cwd / "CLAUDE.md"
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return CheckResult(label="goodvibes sentinel block", passed=False, remedy="Run: goodvibes init")
|
|
79
|
+
content = path.read_text(encoding="utf-8")
|
|
80
|
+
ok = SENTINEL_START in content and SENTINEL_END in content
|
|
81
|
+
return CheckResult(
|
|
82
|
+
label="goodvibes sentinel block",
|
|
83
|
+
passed=ok,
|
|
84
|
+
remedy="" if ok else "Run: goodvibes init (will merge sentinel block)",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def doctor_cmd() -> None:
|
|
89
|
+
"""Check that goodvibes setup is complete."""
|
|
90
|
+
cwd = pathlib.Path.cwd()
|
|
91
|
+
|
|
92
|
+
results = [
|
|
93
|
+
_check_headroom(),
|
|
94
|
+
_check_git_config("user.name"),
|
|
95
|
+
_check_git_config("user.email"),
|
|
96
|
+
_check_claude_md(cwd),
|
|
97
|
+
_check_sentinel(cwd),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
version = _installed_version()
|
|
101
|
+
lines = [f"goodvibes v{version}"] + [f"{'✓' if r.passed else '✗'} {r.label}" for r in results]
|
|
102
|
+
console.print(Panel("\n".join(lines), title="goodvibes doctor"))
|
|
103
|
+
|
|
104
|
+
failures = [r for r in results if not r.passed]
|
|
105
|
+
if failures:
|
|
106
|
+
remediation = "\n".join(r.remedy for r in failures if r.remedy)
|
|
107
|
+
console.print(Panel(remediation, title="How to fix"))
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
|
|
110
|
+
console.rule("[green]All checks passed.[/green]")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""goodvibes init command — port of init.ts."""
|
|
2
|
+
import pathlib
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
from goodvibes_cli.steps.configure_mcp import configure_mcp
|
|
10
|
+
from goodvibes_cli.steps.copy_templates import copy_templates, list_template_files, resolve_templates_dir
|
|
11
|
+
from goodvibes_cli.steps.install_headroom import install_headroom
|
|
12
|
+
from goodvibes_cli.utils.detect_project_type import detect_project_type
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
_NEXT_STEPS = (
|
|
17
|
+
"1. Open this project in your AI coding tool\n"
|
|
18
|
+
"2. Claude Code users: /plugin marketplace add DietrichGebert/ponytail\n"
|
|
19
|
+
" Other IDEs (Cursor, Windsurf, Kiro, Antigravity, etc.): rules already active\n"
|
|
20
|
+
"3. Start coding — CLAUDE.md rules are already active"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def init_cmd(
|
|
25
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview files without writing")] = False,
|
|
26
|
+
minimal: Annotated[bool, typer.Option("--minimal", help="Skip headroom install and CI workflows")] = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Bootstrap a project with goodvibes configuration."""
|
|
29
|
+
template_dir = resolve_templates_dir()
|
|
30
|
+
cwd = pathlib.Path.cwd()
|
|
31
|
+
project_type = detect_project_type(cwd)
|
|
32
|
+
|
|
33
|
+
console.rule("[bold]goodvibes init[/bold]")
|
|
34
|
+
|
|
35
|
+
if dry_run:
|
|
36
|
+
if minimal:
|
|
37
|
+
all_files = list_template_files(template_dir)
|
|
38
|
+
files = [f for f in all_files if not f.startswith(".github") and not f.startswith("docs")]
|
|
39
|
+
else:
|
|
40
|
+
files_tuple = copy_templates(template_dir, cwd, dry_run=True, minimal=False, project_type=project_type)
|
|
41
|
+
files = files_tuple[0]
|
|
42
|
+
file_list = "\n".join(f" Would write: {f}" for f in files)
|
|
43
|
+
console.print(Panel(file_list, title="Dry run — no files written"))
|
|
44
|
+
if minimal:
|
|
45
|
+
console.print(Panel(
|
|
46
|
+
"CI workflows and docs were skipped.\nRun goodvibes init without --minimal to add them.",
|
|
47
|
+
title="Skipped layers"
|
|
48
|
+
))
|
|
49
|
+
console.print(Panel(_NEXT_STEPS, title="Next steps"))
|
|
50
|
+
console.rule("Run without --dry-run to apply these changes.")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Non-empty directory notice (UX-01)
|
|
54
|
+
existing = [e for e in cwd.iterdir() if e.name not in (".git", ".DS_Store")]
|
|
55
|
+
if existing:
|
|
56
|
+
console.print(Panel("Existing files will not be overwritten.", title="Non-empty project detected"))
|
|
57
|
+
|
|
58
|
+
created_files: list[str] = []
|
|
59
|
+
skipped_files_list: list[str] = []
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
with console.status("Copying template files") as status:
|
|
63
|
+
def log_copy(msg: str) -> None:
|
|
64
|
+
status.update(msg)
|
|
65
|
+
|
|
66
|
+
written, skipped = copy_templates(template_dir, cwd, dry_run=False, minimal=minimal, project_type=project_type)
|
|
67
|
+
created_files.extend(written)
|
|
68
|
+
skipped_files_list.extend(skipped)
|
|
69
|
+
|
|
70
|
+
if not minimal:
|
|
71
|
+
with console.status("Installing headroom") as status:
|
|
72
|
+
def log_install(msg: str) -> None:
|
|
73
|
+
status.update(msg)
|
|
74
|
+
|
|
75
|
+
install_headroom(log_install)
|
|
76
|
+
|
|
77
|
+
with console.status("Configuring headroom MCP") as status:
|
|
78
|
+
def log_mcp(msg: str) -> None:
|
|
79
|
+
status.update(msg)
|
|
80
|
+
|
|
81
|
+
configure_mcp(log_mcp)
|
|
82
|
+
except PermissionError as e:
|
|
83
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
84
|
+
console.print("[yellow]Fix:[/yellow] Make sure you are inside your project directory before running this command.")
|
|
85
|
+
console.print(" If permissions are the issue: [bold]chmod u+w .[/bold] (macOS/Linux)")
|
|
86
|
+
raise typer.Exit(1)
|
|
87
|
+
except (OSError, Exception) as e:
|
|
88
|
+
console.print(f"[red]Unexpected error:[/red] {e}")
|
|
89
|
+
raise typer.Exit(1)
|
|
90
|
+
|
|
91
|
+
written_str = "\n".join(created_files) if created_files else "(none)"
|
|
92
|
+
console.print(Panel(written_str, title=f"Files written ({len(created_files)})"))
|
|
93
|
+
if skipped_files_list:
|
|
94
|
+
skipped_str = "\n".join(skipped_files_list)
|
|
95
|
+
console.print(Panel(skipped_str, title=f"Files skipped ({len(skipped_files_list)})"))
|
|
96
|
+
console.print(Panel(_NEXT_STEPS, title="Next steps"))
|
|
97
|
+
if minimal:
|
|
98
|
+
console.print(Panel(
|
|
99
|
+
"CI workflows and docs were skipped.\nRun goodvibes init without --minimal to add them.",
|
|
100
|
+
title="Skipped layers"
|
|
101
|
+
))
|
|
102
|
+
console.rule("[green]You're all set![/green]")
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""goodvibes upgrade command — port of upgrade.ts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
|
|
18
|
+
from goodvibes_cli.steps.copy_templates import list_template_files, resolve_templates_dir
|
|
19
|
+
from goodvibes_cli.utils.detect_project_type import detect_project_type
|
|
20
|
+
from goodvibes_cli.utils.sentinel_merge import (
|
|
21
|
+
SENTINEL_END,
|
|
22
|
+
SENTINEL_START,
|
|
23
|
+
extract_version,
|
|
24
|
+
merge_claude,
|
|
25
|
+
version_gte,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
console = Console()
|
|
29
|
+
|
|
30
|
+
_PYPI_URL = "https://pypi.org/pypi/goodvibes-cli/json"
|
|
31
|
+
_UPGRADING_ENV = "_GV_UPGRADING"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_package_version() -> str | None:
|
|
35
|
+
try:
|
|
36
|
+
return importlib.metadata.version("goodvibes-cli")
|
|
37
|
+
except importlib.metadata.PackageNotFoundError:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _check_pypi_version() -> str | None:
|
|
42
|
+
try:
|
|
43
|
+
with urllib.request.urlopen(_PYPI_URL, timeout=5) as resp: # noqa: S310
|
|
44
|
+
return json.loads(resp.read())["info"]["version"]
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _self_update_pip() -> None:
|
|
50
|
+
# try uv tool upgrade first; fall back to pip install --upgrade
|
|
51
|
+
try:
|
|
52
|
+
subprocess.run(["uv", "tool", "upgrade", "goodvibes-cli"], check=True)
|
|
53
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
54
|
+
subprocess.run(
|
|
55
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "goodvibes-cli"],
|
|
56
|
+
check=True,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
_MANAGED_FIXED = {
|
|
60
|
+
"CLAUDE.md",
|
|
61
|
+
".github/workflows/ci.yml",
|
|
62
|
+
".github/workflows/security.yml",
|
|
63
|
+
".github/workflows/dependency-review.yml",
|
|
64
|
+
".github/dependabot.yml",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _detect_installed_version(cwd: pathlib.Path) -> str | None:
|
|
69
|
+
claude_path = cwd / "CLAUDE.md"
|
|
70
|
+
if not claude_path.exists():
|
|
71
|
+
return None
|
|
72
|
+
return extract_version(claude_path.read_text(encoding="utf-8"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def compute_changes(
|
|
76
|
+
template_dir: pathlib.Path,
|
|
77
|
+
dest_dir: pathlib.Path,
|
|
78
|
+
project_type: str,
|
|
79
|
+
) -> list[tuple[str, str]]:
|
|
80
|
+
"""Return list of (relative_path, status) for managed files."""
|
|
81
|
+
if not template_dir:
|
|
82
|
+
return []
|
|
83
|
+
all_files = list_template_files(template_dir)
|
|
84
|
+
ci_variant = f".github/workflows/ci-{project_type}.yml"
|
|
85
|
+
managed = [
|
|
86
|
+
f for f in all_files
|
|
87
|
+
if f.startswith(".claude/skills/")
|
|
88
|
+
or f in _MANAGED_FIXED
|
|
89
|
+
or f == ci_variant
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
results: list[tuple[str, str]] = []
|
|
93
|
+
for rel in managed:
|
|
94
|
+
# Map CI variant to its destination path
|
|
95
|
+
dest_rel = ".github/workflows/ci.yml" if rel == ci_variant else rel
|
|
96
|
+
dest_path = dest_dir / dest_rel
|
|
97
|
+
src_path = template_dir / rel
|
|
98
|
+
|
|
99
|
+
if not dest_path.exists():
|
|
100
|
+
results.append((dest_rel, "new"))
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
src_content = src_path.read_text(encoding="utf-8")
|
|
104
|
+
dest_content = dest_path.read_text(encoding="utf-8")
|
|
105
|
+
status = "unchanged" if src_content == dest_content else "changed"
|
|
106
|
+
results.append((dest_rel, status))
|
|
107
|
+
|
|
108
|
+
return sorted(results, key=lambda t: t[0])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def format_change_summary(changes: list[tuple[str, str]]) -> str:
|
|
112
|
+
if not changes:
|
|
113
|
+
return "(no managed files found)"
|
|
114
|
+
symbol = {"changed": "updated", "unchanged": "unchanged", "new": "new"}
|
|
115
|
+
return "\n".join(f"{symbol.get(s, '?')} {p}" for p, s in changes)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def upgrade_templates(
|
|
119
|
+
template_dir: pathlib.Path,
|
|
120
|
+
dest_dir: pathlib.Path,
|
|
121
|
+
project_type: str,
|
|
122
|
+
) -> list[str]:
|
|
123
|
+
"""Overwrite managed files from template_dir into dest_dir."""
|
|
124
|
+
ci_variants = {"ci-node.yml", "ci-python.yml", "ci-both.yml"}
|
|
125
|
+
selected_variant = f"ci-{project_type}.yml"
|
|
126
|
+
claude_dest = dest_dir / "CLAUDE.md"
|
|
127
|
+
|
|
128
|
+
if template_dir:
|
|
129
|
+
def ignore_fn(directory: str, contents: list[str]) -> set[str]:
|
|
130
|
+
ignored: set[str] = set()
|
|
131
|
+
for name in contents:
|
|
132
|
+
full = pathlib.Path(directory) / name
|
|
133
|
+
try:
|
|
134
|
+
full.resolve().relative_to(template_dir.resolve()) # raises if symlink escapes
|
|
135
|
+
except ValueError:
|
|
136
|
+
ignored.add(name)
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
rel = full.relative_to(template_dir)
|
|
140
|
+
except ValueError:
|
|
141
|
+
ignored.add(name)
|
|
142
|
+
continue
|
|
143
|
+
if name == "CLAUDE.md":
|
|
144
|
+
ignored.add(name) # handled by merge_claude
|
|
145
|
+
rel_str = str(rel)
|
|
146
|
+
# Skip files outside the managed set
|
|
147
|
+
if not any(
|
|
148
|
+
[".claude/skills" in rel_str, ".github/workflows" in rel_str]
|
|
149
|
+
) and name not in {"CLAUDE.md"}:
|
|
150
|
+
ignored.add(name)
|
|
151
|
+
# Skip non-selected CI variants
|
|
152
|
+
if name in ci_variants and name != selected_variant:
|
|
153
|
+
ignored.add(name)
|
|
154
|
+
return ignored
|
|
155
|
+
|
|
156
|
+
shutil.copytree(str(template_dir), str(dest_dir), ignore=ignore_fn, dirs_exist_ok=True)
|
|
157
|
+
|
|
158
|
+
# Rename selected CI variant to ci.yml
|
|
159
|
+
variant_path = dest_dir / ".github" / "workflows" / selected_variant
|
|
160
|
+
ci_path = dest_dir / ".github" / "workflows" / "ci.yml"
|
|
161
|
+
if variant_path.exists():
|
|
162
|
+
variant_path.rename(ci_path)
|
|
163
|
+
|
|
164
|
+
template_content = (template_dir / "CLAUDE.md").read_text(encoding="utf-8")
|
|
165
|
+
merge_claude(claude_dest, template_content)
|
|
166
|
+
else:
|
|
167
|
+
# template_dir unavailable — still call merge_claude so CLAUDE.md update path is exercised
|
|
168
|
+
if claude_dest.exists():
|
|
169
|
+
merge_claude(claude_dest, claude_dest.read_text(encoding="utf-8"))
|
|
170
|
+
|
|
171
|
+
# Return relative paths written — only managed prefixes
|
|
172
|
+
if not dest_dir.exists():
|
|
173
|
+
return []
|
|
174
|
+
return sorted(
|
|
175
|
+
str(f.relative_to(dest_dir))
|
|
176
|
+
for f in dest_dir.rglob("*")
|
|
177
|
+
if f.is_file()
|
|
178
|
+
and (
|
|
179
|
+
str(f.relative_to(dest_dir)).startswith(".claude/skills/")
|
|
180
|
+
or str(f.relative_to(dest_dir)).startswith(".github/workflows/")
|
|
181
|
+
or str(f.relative_to(dest_dir)) == "CLAUDE.md"
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def upgrade_cmd(
|
|
187
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview changes without writing")] = False,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Re-sync goodvibes-managed files to the latest version."""
|
|
190
|
+
console.rule("[bold]goodvibes upgrade[/bold]")
|
|
191
|
+
|
|
192
|
+
# Self-update: check PyPI for a newer package version and re-exec if found.
|
|
193
|
+
# _GV_UPGRADING prevents infinite re-exec if the new binary still sees itself as outdated.
|
|
194
|
+
if not os.environ.get(_UPGRADING_ENV):
|
|
195
|
+
current = _get_package_version()
|
|
196
|
+
latest = _check_pypi_version()
|
|
197
|
+
if latest and current and not version_gte(current, latest):
|
|
198
|
+
console.print(f"New version available: [bold]{latest}[/bold] (installed: {current})")
|
|
199
|
+
with console.status(f"Updating goodvibes {current} → {latest}…"):
|
|
200
|
+
_self_update_pip()
|
|
201
|
+
console.print(f"[green]✓ Updated to {latest}[/green] — re-applying templates…")
|
|
202
|
+
os.execve(sys.argv[0], sys.argv, {**os.environ, _UPGRADING_ENV: "1"})
|
|
203
|
+
return # unreachable; satisfies type checker
|
|
204
|
+
|
|
205
|
+
template_dir = resolve_templates_dir()
|
|
206
|
+
cwd = pathlib.Path.cwd()
|
|
207
|
+
project_type = detect_project_type(cwd)
|
|
208
|
+
|
|
209
|
+
installed_version = _detect_installed_version(cwd)
|
|
210
|
+
bundled_version = _get_package_version()
|
|
211
|
+
|
|
212
|
+
if installed_version and bundled_version and version_gte(installed_version, bundled_version):
|
|
213
|
+
console.rule(f"[green]Already up to date (v{installed_version})[/green]")
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
changes = compute_changes(template_dir, cwd, project_type)
|
|
217
|
+
console.print(Panel(format_change_summary(changes), title="What will change"))
|
|
218
|
+
|
|
219
|
+
if dry_run:
|
|
220
|
+
console.rule("Run without --dry-run to apply these changes.")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
updated: list[str] = []
|
|
224
|
+
with console.status("Upgrading managed files"):
|
|
225
|
+
updated = upgrade_templates(template_dir, cwd, project_type)
|
|
226
|
+
|
|
227
|
+
file_list = "\n".join(updated) if updated else "(no files changed)"
|
|
228
|
+
console.print(Panel(file_list, title="Files updated"))
|
|
229
|
+
console.rule("[green]Upgrade complete![/green]")
|
goodvibes_cli/main.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from goodvibes_cli.commands.doctor_cmd import doctor_cmd
|
|
6
|
+
from goodvibes_cli.commands.init_cmd import init_cmd
|
|
7
|
+
from goodvibes_cli.commands.upgrade_cmd import upgrade_cmd
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="goodvibes — one-command bootstrap for vibe coding projects")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _version_callback(value: bool) -> None:
|
|
13
|
+
if value:
|
|
14
|
+
version = importlib.metadata.version("goodvibes-cli")
|
|
15
|
+
typer.echo(f"goodvibes {version}")
|
|
16
|
+
raise typer.Exit()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.callback()
|
|
20
|
+
def _callback(
|
|
21
|
+
version: bool = typer.Option(None, "--version", callback=_version_callback, is_eager=True, help="Show version"),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""goodvibes CLI — run 'goodvibes init' to bootstrap a project"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
app.command("init")(init_cmd)
|
|
27
|
+
app.command("upgrade")(upgrade_cmd)
|
|
28
|
+
app.command("update")(upgrade_cmd)
|
|
29
|
+
app.command("doctor")(doctor_cmd)
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Register headroom as a global MCP server in Claude Code."""
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def configure_mcp(log: Callable[[str], None]) -> None:
|
|
8
|
+
"""Register headroom as a global MCP server via claude mcp add (primary) or headroom mcp install (fallback).
|
|
9
|
+
|
|
10
|
+
Strategy:
|
|
11
|
+
1. Idempotency: headroom mcp status (exit 0 → already registered)
|
|
12
|
+
2. Primary: claude mcp add -s user headroom <absolute-path>
|
|
13
|
+
3. Fallback: headroom mcp install (when claude CLI not on PATH)
|
|
14
|
+
|
|
15
|
+
Never writes to ~/.claude/ directly. Never uses shell=True.
|
|
16
|
+
"""
|
|
17
|
+
# Step 1: idempotency check
|
|
18
|
+
try:
|
|
19
|
+
subprocess.run(
|
|
20
|
+
["headroom", "mcp", "status"],
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
check=True,
|
|
24
|
+
)
|
|
25
|
+
log("headroom MCP already configured — skipping")
|
|
26
|
+
return
|
|
27
|
+
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
# Step 2: primary — claude mcp add -s user (handles CLAUDE_CONFIG_DIR correctly)
|
|
31
|
+
try:
|
|
32
|
+
list_result = subprocess.run(
|
|
33
|
+
["claude", "mcp", "list"],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
check=True,
|
|
37
|
+
)
|
|
38
|
+
if "headroom" in list_result.stdout:
|
|
39
|
+
log("headroom already registered in claude MCP — skipping")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
absolute_path = shutil.which("headroom")
|
|
43
|
+
if not absolute_path:
|
|
44
|
+
log(
|
|
45
|
+
"headroom binary not found on PATH — MCP registration skipped. "
|
|
46
|
+
'Run `uv tool install "headroom-ai[all]"` then re-run `goodvibes init`.'
|
|
47
|
+
)
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
subprocess.run(
|
|
51
|
+
["claude", "mcp", "add", "-s", "user", "headroom", absolute_path],
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
check=True,
|
|
55
|
+
)
|
|
56
|
+
log("headroom registered as global MCP server")
|
|
57
|
+
return
|
|
58
|
+
except FileNotFoundError:
|
|
59
|
+
# claude CLI not on PATH — fall back to headroom mcp install
|
|
60
|
+
log("claude CLI not found — falling back to headroom mcp install")
|
|
61
|
+
log(
|
|
62
|
+
"Warning: if you use CLAUDE_CONFIG_DIR, you may need to run "
|
|
63
|
+
"`headroom mcp install` manually"
|
|
64
|
+
)
|
|
65
|
+
except subprocess.CalledProcessError as e:
|
|
66
|
+
lines = (e.stderr or "").splitlines()
|
|
67
|
+
log(f"claude mcp add failed: {lines[0] if lines else 'unknown error'}")
|
|
68
|
+
log("Run `headroom mcp install` manually.")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
# Step 3: fallback — headroom mcp install
|
|
72
|
+
try:
|
|
73
|
+
subprocess.run(
|
|
74
|
+
["headroom", "mcp", "install"],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
check=True,
|
|
78
|
+
)
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
log(
|
|
81
|
+
"headroom binary not found — MCP registration skipped. "
|
|
82
|
+
"Install headroom and run `headroom mcp install` manually."
|
|
83
|
+
)
|
|
84
|
+
except subprocess.CalledProcessError as e:
|
|
85
|
+
lines = (e.stderr or "").splitlines()
|
|
86
|
+
first_line = lines[0] if lines else "unknown error"
|
|
87
|
+
log(
|
|
88
|
+
f"headroom MCP install failed: {first_line}. "
|
|
89
|
+
"Run `headroom mcp install` manually to complete MCP setup."
|
|
90
|
+
)
|
|
91
|
+
return
|