MertCapkin-GraphStack 4.5.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.
- graphstack/__init__.py +12 -0
- graphstack/__main__.py +10 -0
- graphstack/assets/docs/CURSOR_PROMPTS.md +215 -0
- graphstack/assets/handoff/BOOTSTRAP.md +73 -0
- graphstack/assets/handoff/BRIEF.md +66 -0
- graphstack/assets/handoff/REVIEW.md +7 -0
- graphstack/assets/handoff/board/README.md +60 -0
- graphstack/assets/orchestrator/ORCHESTRATOR.md +416 -0
- graphstack/assets/orchestrator/TOKEN_OPTIMIZER.md +319 -0
- graphstack/assets/scripts/board.ps1 +37 -0
- graphstack/assets/scripts/board.sh +22 -0
- graphstack/assets/scripts/gate-hook.ps1 +41 -0
- graphstack/assets/scripts/gate-hook.sh +26 -0
- graphstack/assets/scripts/post-commit +20 -0
- graphstack/assets/scripts/post-commit.ps1 +44 -0
- graphstack/board.py +361 -0
- graphstack/bootstrap.py +50 -0
- graphstack/cli.py +99 -0
- graphstack/compact/__init__.py +9 -0
- graphstack/compact/__pycache__/__init__.cpython-311.pyc +0 -0
- graphstack/compact/__pycache__/base.cpython-311.pyc +0 -0
- graphstack/compact/__pycache__/generic.cpython-311.pyc +0 -0
- graphstack/compact/__pycache__/git.cpython-311.pyc +0 -0
- graphstack/compact/__pycache__/registry.cpython-311.pyc +0 -0
- graphstack/compact/base.py +115 -0
- graphstack/compact/generic.py +90 -0
- graphstack/compact/git.py +167 -0
- graphstack/compact/registry.py +47 -0
- graphstack/constants.py +38 -0
- graphstack/gate.py +429 -0
- graphstack/graph.py +143 -0
- graphstack/hook.py +144 -0
- graphstack/init_cmd.py +113 -0
- graphstack/installer.py +366 -0
- graphstack/platform_utils.py +127 -0
- graphstack/run.py +103 -0
- graphstack/state.py +117 -0
- graphstack/tests/__init__.py +0 -0
- graphstack/tests/conftest.py +30 -0
- graphstack/tests/test_assets.py +35 -0
- graphstack/tests/test_board.py +166 -0
- graphstack/tests/test_compact.py +93 -0
- graphstack/tests/test_gate.py +406 -0
- graphstack/tests/test_graph.py +60 -0
- graphstack/tests/test_hook.py +57 -0
- graphstack/tests/test_init.py +58 -0
- graphstack/tests/test_installer.py +73 -0
- graphstack/tests/test_platform_utils.py +69 -0
- graphstack/tests/test_state.py +56 -0
- graphstack/tests/test_validate.py +204 -0
- graphstack/validate.py +469 -0
- mertcapkin_graphstack-4.5.1.dist-info/METADATA +720 -0
- mertcapkin_graphstack-4.5.1.dist-info/RECORD +57 -0
- mertcapkin_graphstack-4.5.1.dist-info/WHEEL +5 -0
- mertcapkin_graphstack-4.5.1.dist-info/entry_points.txt +2 -0
- mertcapkin_graphstack-4.5.1.dist-info/licenses/LICENSE +21 -0
- mertcapkin_graphstack-4.5.1.dist-info/top_level.txt +1 -0
graphstack/hook.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Smart graph-update logic — pure Python port of ``scripts/post-commit``.
|
|
2
|
+
|
|
3
|
+
Triggers on:
|
|
4
|
+
- new/deleted files (structural change)
|
|
5
|
+
- ``Ship`` commits (``board: complete``, ``[ship]``, ``ship:`` prefixes)
|
|
6
|
+
- staleness > 24 hours
|
|
7
|
+
|
|
8
|
+
Skips on:
|
|
9
|
+
- pure content edits to existing files
|
|
10
|
+
|
|
11
|
+
Improvements over bash original:
|
|
12
|
+
- Handles the *first* commit gracefully (no ``HEAD~1`` to diff against)
|
|
13
|
+
- Cross-platform mtime check (``date -r`` / ``stat -c`` portability solved)
|
|
14
|
+
- Uses Python regex instead of grep -E
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import re
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .constants import GRAPH_HTML, GRAPH_JSON, GRAPH_REPORT, GRAPHIFY_OUT, STALE_GRAPH_HOURS
|
|
27
|
+
from .platform_utils import echo, file_mtime_seconds, graphify_available, run_git
|
|
28
|
+
|
|
29
|
+
SHIP_COMMIT_PATTERN = re.compile(r"^(?:board: complete|\[ship\]|ship:)", re.IGNORECASE)
|
|
30
|
+
EXCLUDE_PREFIXES = ("graphify-out/", "handoff/")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _has_previous_commit() -> bool:
|
|
34
|
+
"""``HEAD~1`` exists only after at least 2 commits — guard for first commit."""
|
|
35
|
+
return run_git("rev-parse", "--verify", "HEAD~1").returncode == 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _structural_changes_count() -> int:
|
|
39
|
+
"""Number of added/deleted files in the latest commit (excluding generated dirs)."""
|
|
40
|
+
if not _has_previous_commit():
|
|
41
|
+
return 0
|
|
42
|
+
result = run_git("diff", "HEAD~1", "--name-status")
|
|
43
|
+
if result.returncode != 0:
|
|
44
|
+
return 0
|
|
45
|
+
count = 0
|
|
46
|
+
for line in result.stdout.splitlines():
|
|
47
|
+
if not line:
|
|
48
|
+
continue
|
|
49
|
+
status, _, path = line.partition("\t")
|
|
50
|
+
if status[:1] not in ("A", "D"):
|
|
51
|
+
continue
|
|
52
|
+
if any(path.startswith(p) for p in EXCLUDE_PREFIXES):
|
|
53
|
+
continue
|
|
54
|
+
count += 1
|
|
55
|
+
return count
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _modified_count() -> int:
|
|
59
|
+
"""Number of files modified in the latest commit (any status, excludes generated)."""
|
|
60
|
+
if not _has_previous_commit():
|
|
61
|
+
return 0
|
|
62
|
+
result = run_git("diff", "HEAD~1", "--name-only")
|
|
63
|
+
if result.returncode != 0:
|
|
64
|
+
return 0
|
|
65
|
+
return sum(
|
|
66
|
+
1 for p in result.stdout.splitlines()
|
|
67
|
+
if p and not any(p.startswith(pre) for pre in EXCLUDE_PREFIXES)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _last_commit_message() -> str:
|
|
72
|
+
result = run_git("log", "-1", "--pretty=%s")
|
|
73
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _do_update(reason: str) -> int:
|
|
77
|
+
if not graphify_available():
|
|
78
|
+
echo("⚠️ GraphStack: graphify not found. Install: pip install graphifyy")
|
|
79
|
+
return 0
|
|
80
|
+
echo(f"🧠 GraphStack: updating graph ({reason})...")
|
|
81
|
+
try:
|
|
82
|
+
proc = subprocess.run(
|
|
83
|
+
["graphify", ".", "--update", "--quiet"],
|
|
84
|
+
check=False,
|
|
85
|
+
)
|
|
86
|
+
except OSError as exc:
|
|
87
|
+
echo(f"⚠️ GraphStack: graphify failed to launch ({exc}).")
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
if proc.returncode != 0:
|
|
91
|
+
echo("⚠️ GraphStack: graphify update failed. Run /graphify --update manually.")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
for artifact in (GRAPH_REPORT, GRAPH_JSON, GRAPH_HTML):
|
|
95
|
+
if artifact.is_file():
|
|
96
|
+
run_git("add", str(artifact))
|
|
97
|
+
echo(f"✅ GraphStack: Graph updated ({reason}) and staged.")
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_hook() -> int:
|
|
102
|
+
if not GRAPH_REPORT.is_file():
|
|
103
|
+
echo("🧠 GraphStack: No graph yet. Run /graphify . manually after first build.")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
structural = _structural_changes_count()
|
|
107
|
+
if structural > 0:
|
|
108
|
+
echo(f"🧠 GraphStack: {structural} file(s) added/deleted — updating graph...")
|
|
109
|
+
return _do_update(f"structural ({structural} files)")
|
|
110
|
+
|
|
111
|
+
msg = _last_commit_message()
|
|
112
|
+
if SHIP_COMMIT_PATTERN.search(msg):
|
|
113
|
+
echo("🧠 GraphStack: Ship commit detected — updating graph...")
|
|
114
|
+
return _do_update("ship commit")
|
|
115
|
+
|
|
116
|
+
age_seconds = file_mtime_seconds(GRAPH_REPORT)
|
|
117
|
+
if age_seconds is not None:
|
|
118
|
+
age_hours = (int(time.time()) - age_seconds) // 3600
|
|
119
|
+
if age_hours > STALE_GRAPH_HOURS:
|
|
120
|
+
echo(f"🧠 GraphStack: Graph is {age_hours}h old — updating...")
|
|
121
|
+
return _do_update(f"stale ({age_hours}h)")
|
|
122
|
+
|
|
123
|
+
changed = _modified_count()
|
|
124
|
+
echo(
|
|
125
|
+
f"🧠 GraphStack: {changed} file(s) modified (content only, graph current) "
|
|
126
|
+
f"— no update needed. ✓"
|
|
127
|
+
)
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
132
|
+
return argparse.ArgumentParser(
|
|
133
|
+
prog="graphstack hook",
|
|
134
|
+
description="Run the GraphStack post-commit hook logic.",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def run(argv: list[str]) -> int:
|
|
139
|
+
_build_parser().parse_args(argv)
|
|
140
|
+
return run_hook()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
sys.exit(run(sys.argv[1:]))
|
graphstack/init_cmd.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""One-shot project bootstrap: install GraphStack + refresh graph + health check.
|
|
2
|
+
|
|
3
|
+
Replaces the manual four-step onboarding (install → graphify → doctor → hooks)
|
|
4
|
+
with a single command for new or existing target projects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .bootstrap import ensure_graphify, run_graphify_cursor_install
|
|
15
|
+
from .graph import run_update as graph_update
|
|
16
|
+
from .installer import install
|
|
17
|
+
from .platform_utils import echo, graphify_available
|
|
18
|
+
from .validate import run_doctor
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(argv: list[str]) -> int:
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog="graphstack init",
|
|
24
|
+
description="Install GraphStack into a project, refresh the code graph, run doctor.",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"target",
|
|
28
|
+
nargs="?",
|
|
29
|
+
default=".",
|
|
30
|
+
help="Target project root (default: current directory).",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"-y", "--non-interactive",
|
|
34
|
+
action="store_true",
|
|
35
|
+
help="Skip interactive install prompts (CI-friendly).",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--skip-graph",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="Skip graphify update even when graphify is installed.",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--install-deps",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="pip install graphifyy if missing; run graphify cursor install.",
|
|
46
|
+
)
|
|
47
|
+
args = parser.parse_args(argv)
|
|
48
|
+
target = Path(args.target).resolve()
|
|
49
|
+
|
|
50
|
+
echo("")
|
|
51
|
+
echo("GraphStack init")
|
|
52
|
+
echo("===============")
|
|
53
|
+
echo(f"Target: {target}")
|
|
54
|
+
echo("")
|
|
55
|
+
|
|
56
|
+
if args.install_deps:
|
|
57
|
+
if not ensure_graphify(install=True):
|
|
58
|
+
echo("Warning: could not install graphify — continue without graph refresh.")
|
|
59
|
+
|
|
60
|
+
rc = install(target, non_interactive=args.non_interactive)
|
|
61
|
+
if rc != 0:
|
|
62
|
+
return rc
|
|
63
|
+
|
|
64
|
+
if args.install_deps and graphify_available():
|
|
65
|
+
echo("")
|
|
66
|
+
echo("Registering Graphify in Cursor (.cursor/rules)...")
|
|
67
|
+
cursor_rc = run_graphify_cursor_install()
|
|
68
|
+
if cursor_rc != 0:
|
|
69
|
+
echo("Warning: graphify cursor install failed — run manually: graphify cursor install")
|
|
70
|
+
|
|
71
|
+
if not args.skip_graph and graphify_available():
|
|
72
|
+
echo("")
|
|
73
|
+
echo("Refreshing code graph (AST-only, no API cost)...")
|
|
74
|
+
prev = Path.cwd()
|
|
75
|
+
try:
|
|
76
|
+
os.chdir(target)
|
|
77
|
+
graph_rc = graph_update(["."])
|
|
78
|
+
finally:
|
|
79
|
+
os.chdir(prev)
|
|
80
|
+
if graph_rc != 0:
|
|
81
|
+
echo("Warning: graph update failed — run manually: graphify update .")
|
|
82
|
+
elif not args.skip_graph:
|
|
83
|
+
echo("")
|
|
84
|
+
echo("Skipping graph update (graphify not on PATH).")
|
|
85
|
+
if not args.install_deps:
|
|
86
|
+
echo(" Tip: re-run with --install-deps or: pip install \"graphifyy>=0.7,<0.9\"")
|
|
87
|
+
|
|
88
|
+
echo("")
|
|
89
|
+
echo("Health check:")
|
|
90
|
+
prev = Path.cwd()
|
|
91
|
+
try:
|
|
92
|
+
os.chdir(target)
|
|
93
|
+
doctor_rc = run_doctor([])
|
|
94
|
+
finally:
|
|
95
|
+
os.chdir(prev)
|
|
96
|
+
|
|
97
|
+
graph_report = target / "graphify-out" / "GRAPH_REPORT.md"
|
|
98
|
+
echo("")
|
|
99
|
+
if graph_report.is_file():
|
|
100
|
+
echo("Ready. Describe your task in Cursor — GraphStack rules load automatically.")
|
|
101
|
+
echo(" graph query: python -m graphstack graph query \"your question\"")
|
|
102
|
+
else:
|
|
103
|
+
echo("Next: build the full knowledge graph in Cursor chat → /graphify .")
|
|
104
|
+
echo(" (code-only graph: graphstack graph update .)")
|
|
105
|
+
echo(" process gate: python -m graphstack gate check")
|
|
106
|
+
echo("")
|
|
107
|
+
|
|
108
|
+
return doctor_rc
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
sys.exit(run(sys.argv[1:]))
|
|
113
|
+
|
graphstack/installer.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""GraphStack installer — pure Python port of ``install.sh``.
|
|
2
|
+
|
|
3
|
+
Improvements over the bash original:
|
|
4
|
+
- Works natively on Windows PowerShell (no Git Bash needed)
|
|
5
|
+
- No ``realpath`` dependency (uses ``pathlib.Path.resolve()``)
|
|
6
|
+
- ``--non-interactive`` / ``-y`` flag for CI use
|
|
7
|
+
- ``.gitkeep`` files in empty board directories so git tracks them
|
|
8
|
+
- Re-entrant: re-running over an existing install only refreshes managed files
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .platform_utils import echo, graphify_available
|
|
20
|
+
|
|
21
|
+
PACKAGE_ROOT = Path(__file__).resolve().parent
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def install_source_root() -> Path:
|
|
25
|
+
"""Workflow files: dev repo checkout, or bundled assets in PyPI wheel."""
|
|
26
|
+
repo = PACKAGE_ROOT.parent.parent
|
|
27
|
+
if (repo / "orchestrator" / "ORCHESTRATOR.md").is_file():
|
|
28
|
+
return repo
|
|
29
|
+
bundled = PACKAGE_ROOT / "assets"
|
|
30
|
+
if (bundled / "orchestrator" / "ORCHESTRATOR.md").is_file():
|
|
31
|
+
return bundled
|
|
32
|
+
raise FileNotFoundError(
|
|
33
|
+
"GraphStack workflow files not found. "
|
|
34
|
+
"Reinstall with: pip install --upgrade MertCapkin_GraphStack"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Back-compat alias used throughout this module.
|
|
39
|
+
def _source_root() -> Path:
|
|
40
|
+
return install_source_root()
|
|
41
|
+
|
|
42
|
+
DIRS_TO_CREATE = (
|
|
43
|
+
".cursor/rules",
|
|
44
|
+
".cursor/skills/architect",
|
|
45
|
+
".cursor/skills/builder",
|
|
46
|
+
".cursor/skills/reviewer",
|
|
47
|
+
".cursor/skills/qa",
|
|
48
|
+
".cursor/skills/ship",
|
|
49
|
+
".cursor/skills/bootstrapper",
|
|
50
|
+
".cursor/commands",
|
|
51
|
+
".claude",
|
|
52
|
+
"orchestrator",
|
|
53
|
+
"handoff/board/todo",
|
|
54
|
+
"handoff/board/doing",
|
|
55
|
+
"handoff/board/done",
|
|
56
|
+
"graphify-out",
|
|
57
|
+
"scripts",
|
|
58
|
+
"scripts/graphstack",
|
|
59
|
+
"docs",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# (source path inside repo, dest path inside target)
|
|
63
|
+
FILE_COPIES = (
|
|
64
|
+
(".cursor/rules/graphstack.mdc", ".cursor/rules/graphstack.mdc"),
|
|
65
|
+
(".cursor/commands/graphstack.md", ".cursor/commands/graphstack.md"),
|
|
66
|
+
("orchestrator/ORCHESTRATOR.md", "orchestrator/ORCHESTRATOR.md"),
|
|
67
|
+
("orchestrator/TOKEN_OPTIMIZER.md", "orchestrator/TOKEN_OPTIMIZER.md"),
|
|
68
|
+
(".cursor/skills/architect/ARCHITECT.md", ".cursor/skills/architect/ARCHITECT.md"),
|
|
69
|
+
(".cursor/skills/builder/BUILDER.md", ".cursor/skills/builder/BUILDER.md"),
|
|
70
|
+
(".cursor/skills/reviewer/REVIEWER.md", ".cursor/skills/reviewer/REVIEWER.md"),
|
|
71
|
+
(".cursor/skills/qa/QA.md", ".cursor/skills/qa/QA.md"),
|
|
72
|
+
(".cursor/skills/ship/SHIP.md", ".cursor/skills/ship/SHIP.md"),
|
|
73
|
+
(".cursor/skills/bootstrapper/BOOTSTRAPPER.md", ".cursor/skills/bootstrapper/BOOTSTRAPPER.md"),
|
|
74
|
+
("handoff/board/README.md", "handoff/board/README.md"),
|
|
75
|
+
("docs/CURSOR_PROMPTS.md", "docs/CURSOR_PROMPTS.md"),
|
|
76
|
+
("scripts/board.sh", "scripts/board.sh"),
|
|
77
|
+
("scripts/board.ps1", "scripts/board.ps1"),
|
|
78
|
+
("scripts/post-commit", "scripts/post-commit"),
|
|
79
|
+
("scripts/post-commit.ps1", "scripts/post-commit.ps1"),
|
|
80
|
+
("scripts/gate-hook.sh", "scripts/gate-hook.sh"),
|
|
81
|
+
("scripts/gate-hook.ps1", "scripts/gate-hook.ps1"),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Handoff files that must NOT overwrite an existing copy in the target.
|
|
85
|
+
HANDOFF_TEMPLATES = (
|
|
86
|
+
("handoff/BRIEF.md", "handoff/BRIEF.md"),
|
|
87
|
+
("handoff/REVIEW.md", "handoff/REVIEW.md"),
|
|
88
|
+
("handoff/BOOTSTRAP.md", "handoff/BOOTSTRAP.md"),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
PYTHON_PACKAGE_FILES = (
|
|
92
|
+
"__init__.py",
|
|
93
|
+
"__main__.py",
|
|
94
|
+
"cli.py",
|
|
95
|
+
"board.py",
|
|
96
|
+
"installer.py",
|
|
97
|
+
"hook.py",
|
|
98
|
+
"validate.py",
|
|
99
|
+
"platform_utils.py",
|
|
100
|
+
"constants.py",
|
|
101
|
+
"run.py",
|
|
102
|
+
"gate.py",
|
|
103
|
+
"state.py",
|
|
104
|
+
"graph.py",
|
|
105
|
+
"init_cmd.py",
|
|
106
|
+
"bootstrap.py",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
COMPACT_PACKAGE_FILES = (
|
|
110
|
+
"__init__.py",
|
|
111
|
+
"base.py",
|
|
112
|
+
"git.py",
|
|
113
|
+
"generic.py",
|
|
114
|
+
"registry.py",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
GITKEEP_DIRS = ("handoff/board/todo", "handoff/board/doing", "handoff/board/done")
|
|
118
|
+
|
|
119
|
+
STATE_TEMPLATE = """# GraphStack Session State
|
|
120
|
+
|
|
121
|
+
> Auto-managed by Orchestrator. Append-only — never delete history.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
<!-- Sessions appended below, newest first -->
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _copy_if_exists(src: Path, dst: Path) -> bool:
|
|
130
|
+
root = _source_root()
|
|
131
|
+
if not src.is_file():
|
|
132
|
+
try:
|
|
133
|
+
rel = src.relative_to(root)
|
|
134
|
+
except ValueError:
|
|
135
|
+
rel = src
|
|
136
|
+
echo(f"⚠️ Missing source file: {rel}")
|
|
137
|
+
return False
|
|
138
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
shutil.copy2(src, dst)
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _ask_yes_no(prompt: str, *, default_no: bool = True, non_interactive: bool = False) -> bool:
|
|
144
|
+
if non_interactive:
|
|
145
|
+
return False
|
|
146
|
+
suffix = " (y/N): " if default_no else " (Y/n): "
|
|
147
|
+
try:
|
|
148
|
+
answer = input(prompt + suffix).strip().lower()
|
|
149
|
+
except EOFError:
|
|
150
|
+
return False
|
|
151
|
+
if not answer:
|
|
152
|
+
return not default_no
|
|
153
|
+
return answer in ("y", "yes")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _install_git_hook(target: Path, non_interactive: bool) -> None:
|
|
157
|
+
git_dir = target / ".git"
|
|
158
|
+
if not git_dir.is_dir():
|
|
159
|
+
return
|
|
160
|
+
if not _ask_yes_no(
|
|
161
|
+
"🔗 Install git post-commit hook for auto graph updates?",
|
|
162
|
+
non_interactive=non_interactive,
|
|
163
|
+
):
|
|
164
|
+
return
|
|
165
|
+
src = _source_root() / "scripts" / "post-commit"
|
|
166
|
+
dst = git_dir / "hooks" / "post-commit"
|
|
167
|
+
if _copy_if_exists(src, dst):
|
|
168
|
+
try:
|
|
169
|
+
dst.chmod(0o755)
|
|
170
|
+
except OSError:
|
|
171
|
+
pass # Windows: chmod is largely symbolic, hook still runs via Git Bash
|
|
172
|
+
echo("✅ Git hook installed.")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _gate_hook_command(platform: str) -> str:
|
|
176
|
+
"""Shell command for hook adapters — OS-aware python launcher."""
|
|
177
|
+
if sys.platform == "win32":
|
|
178
|
+
return (
|
|
179
|
+
f"powershell -NoProfile -ExecutionPolicy Bypass "
|
|
180
|
+
f"-File scripts/gate-hook.ps1 {platform}"
|
|
181
|
+
)
|
|
182
|
+
return f"bash scripts/gate-hook.sh {platform}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _cursor_hooks_payload() -> dict:
|
|
186
|
+
cmd = _gate_hook_command("cursor")
|
|
187
|
+
hook_entry = {"command": cmd}
|
|
188
|
+
return {
|
|
189
|
+
"version": 1,
|
|
190
|
+
"hooks": {
|
|
191
|
+
"preToolUse": [{**hook_entry, "matcher": "Write|Shell|Delete|Edit"}],
|
|
192
|
+
"beforeShellExecution": [hook_entry],
|
|
193
|
+
"afterFileEdit": [hook_entry],
|
|
194
|
+
"stop": [hook_entry],
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _claude_settings_payload() -> dict:
|
|
200
|
+
cmd = _gate_hook_command("claude")
|
|
201
|
+
return {
|
|
202
|
+
"hooks": {
|
|
203
|
+
"PreToolUse": [
|
|
204
|
+
{
|
|
205
|
+
"matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash",
|
|
206
|
+
"hooks": [
|
|
207
|
+
{"type": "command", "command": cmd, "timeout": 30}
|
|
208
|
+
],
|
|
209
|
+
}
|
|
210
|
+
],
|
|
211
|
+
"Stop": [
|
|
212
|
+
{
|
|
213
|
+
"hooks": [
|
|
214
|
+
{"type": "command", "command": cmd, "timeout": 30}
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
],
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _install_hook_adapters(target: Path) -> None:
|
|
223
|
+
"""Write Cursor / Claude Code hook adapters with OS-specific shim commands.
|
|
224
|
+
|
|
225
|
+
Never overwrites an existing adapter — projects may have their own hooks
|
|
226
|
+
configured and merging JSON automatically is riskier than skipping.
|
|
227
|
+
"""
|
|
228
|
+
adapters = (
|
|
229
|
+
(target / ".cursor" / "hooks.json", _cursor_hooks_payload()),
|
|
230
|
+
(target / ".claude" / "settings.json", _claude_settings_payload()),
|
|
231
|
+
)
|
|
232
|
+
for dst, payload in adapters:
|
|
233
|
+
if dst.exists():
|
|
234
|
+
echo(f"⏭️ {dst.relative_to(target)} already exists — skipping")
|
|
235
|
+
continue
|
|
236
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
dst.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
238
|
+
echo("🛡️ Process-gate hooks installed (.cursor/hooks.json, .claude/settings.json)")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _ensure_state_md(target: Path) -> None:
|
|
242
|
+
state = target / "handoff" / "STATE.md"
|
|
243
|
+
if state.exists():
|
|
244
|
+
return
|
|
245
|
+
state.parent.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
state.write_text(STATE_TEMPLATE, encoding="utf-8")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _install_python_package(target: Path) -> int:
|
|
250
|
+
"""Copy ``scripts/graphstack/*.py`` into the target so the shims work."""
|
|
251
|
+
src_pkg = PACKAGE_ROOT
|
|
252
|
+
dst_pkg = target / "scripts" / "graphstack"
|
|
253
|
+
dst_pkg.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
copied = 0
|
|
255
|
+
for name in PYTHON_PACKAGE_FILES:
|
|
256
|
+
src = src_pkg / name
|
|
257
|
+
if not src.is_file():
|
|
258
|
+
echo(f"⚠️ Missing package file: scripts/graphstack/{name}")
|
|
259
|
+
continue
|
|
260
|
+
shutil.copy2(src, dst_pkg / name)
|
|
261
|
+
copied += 1
|
|
262
|
+
|
|
263
|
+
compact_src = src_pkg / "compact"
|
|
264
|
+
compact_dst = dst_pkg / "compact"
|
|
265
|
+
if compact_src.is_dir():
|
|
266
|
+
compact_dst.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
for name in COMPACT_PACKAGE_FILES:
|
|
268
|
+
src = compact_src / name
|
|
269
|
+
if src.is_file():
|
|
270
|
+
shutil.copy2(src, compact_dst / name)
|
|
271
|
+
copied += 1
|
|
272
|
+
return copied
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def install(target: Path, *, non_interactive: bool = False) -> int:
|
|
276
|
+
target = target.resolve()
|
|
277
|
+
root = _source_root()
|
|
278
|
+
|
|
279
|
+
echo("")
|
|
280
|
+
echo("🧠 GraphStack Installer")
|
|
281
|
+
echo("=====================")
|
|
282
|
+
echo(f"Target: {target}")
|
|
283
|
+
echo("")
|
|
284
|
+
|
|
285
|
+
echo("📁 Creating directories...")
|
|
286
|
+
for rel in DIRS_TO_CREATE:
|
|
287
|
+
(target / rel).mkdir(parents=True, exist_ok=True)
|
|
288
|
+
|
|
289
|
+
echo("📋 Installing Cursor rules, orchestrator and role skills...")
|
|
290
|
+
for src_rel, dst_rel in FILE_COPIES:
|
|
291
|
+
_copy_if_exists(root / src_rel, target / dst_rel)
|
|
292
|
+
|
|
293
|
+
echo("🐍 Installing Python helper package...")
|
|
294
|
+
package_count = _install_python_package(target)
|
|
295
|
+
echo(f" {package_count} package files copied to scripts/graphstack/")
|
|
296
|
+
|
|
297
|
+
# Make the unix shims executable; on Windows this is a no-op symbolic chmod.
|
|
298
|
+
for rel in ("scripts/board.sh", "scripts/post-commit", "scripts/gate-hook.sh"):
|
|
299
|
+
path = target / rel
|
|
300
|
+
if path.is_file():
|
|
301
|
+
try:
|
|
302
|
+
path.chmod(0o755)
|
|
303
|
+
except OSError:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
if not (target / "handoff" / "BRIEF.md").exists():
|
|
307
|
+
echo("📝 Creating handoff templates...")
|
|
308
|
+
for src_rel, dst_rel in HANDOFF_TEMPLATES:
|
|
309
|
+
_copy_if_exists(root / src_rel, target / dst_rel)
|
|
310
|
+
else:
|
|
311
|
+
echo("⏭️ Handoff files already exist — skipping (not overwriting)")
|
|
312
|
+
|
|
313
|
+
_ensure_state_md(target)
|
|
314
|
+
_install_hook_adapters(target)
|
|
315
|
+
|
|
316
|
+
for rel in GITKEEP_DIRS:
|
|
317
|
+
keep = target / rel / ".gitkeep"
|
|
318
|
+
if not keep.exists():
|
|
319
|
+
keep.write_text("", encoding="utf-8")
|
|
320
|
+
|
|
321
|
+
_install_git_hook(target, non_interactive)
|
|
322
|
+
|
|
323
|
+
echo("")
|
|
324
|
+
if graphify_available():
|
|
325
|
+
echo("✅ graphify is installed.")
|
|
326
|
+
else:
|
|
327
|
+
echo("⚠️ graphify not found. Install it with:")
|
|
328
|
+
echo(" pip install \"graphifyy>=0.7,<0.9\"")
|
|
329
|
+
|
|
330
|
+
echo("")
|
|
331
|
+
echo("🎉 GraphStack v4 installed!")
|
|
332
|
+
echo("")
|
|
333
|
+
echo("Next steps:")
|
|
334
|
+
echo(" 1. Build graph: open Cursor in your project → type: /graphify .")
|
|
335
|
+
echo(" 2. Start working: paste the prompt from docs/CURSOR_PROMPTS.md")
|
|
336
|
+
echo("")
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
341
|
+
p = argparse.ArgumentParser(
|
|
342
|
+
prog="graphstack install",
|
|
343
|
+
description="Install GraphStack into a target project directory.",
|
|
344
|
+
)
|
|
345
|
+
p.add_argument(
|
|
346
|
+
"target",
|
|
347
|
+
nargs="?",
|
|
348
|
+
default=".",
|
|
349
|
+
help="Target project root (defaults to current directory).",
|
|
350
|
+
)
|
|
351
|
+
p.add_argument(
|
|
352
|
+
"-y", "--non-interactive",
|
|
353
|
+
action="store_true",
|
|
354
|
+
help="Skip all interactive prompts (CI-friendly). Implies 'no' to optional features.",
|
|
355
|
+
)
|
|
356
|
+
return p
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def run(argv: list[str]) -> int:
|
|
360
|
+
args = _build_parser().parse_args(argv)
|
|
361
|
+
target = Path(args.target)
|
|
362
|
+
return install(target, non_interactive=args.non_interactive)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
if __name__ == "__main__":
|
|
366
|
+
sys.exit(run(sys.argv[1:]))
|