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.
Files changed (57) hide show
  1. graphstack/__init__.py +12 -0
  2. graphstack/__main__.py +10 -0
  3. graphstack/assets/docs/CURSOR_PROMPTS.md +215 -0
  4. graphstack/assets/handoff/BOOTSTRAP.md +73 -0
  5. graphstack/assets/handoff/BRIEF.md +66 -0
  6. graphstack/assets/handoff/REVIEW.md +7 -0
  7. graphstack/assets/handoff/board/README.md +60 -0
  8. graphstack/assets/orchestrator/ORCHESTRATOR.md +416 -0
  9. graphstack/assets/orchestrator/TOKEN_OPTIMIZER.md +319 -0
  10. graphstack/assets/scripts/board.ps1 +37 -0
  11. graphstack/assets/scripts/board.sh +22 -0
  12. graphstack/assets/scripts/gate-hook.ps1 +41 -0
  13. graphstack/assets/scripts/gate-hook.sh +26 -0
  14. graphstack/assets/scripts/post-commit +20 -0
  15. graphstack/assets/scripts/post-commit.ps1 +44 -0
  16. graphstack/board.py +361 -0
  17. graphstack/bootstrap.py +50 -0
  18. graphstack/cli.py +99 -0
  19. graphstack/compact/__init__.py +9 -0
  20. graphstack/compact/__pycache__/__init__.cpython-311.pyc +0 -0
  21. graphstack/compact/__pycache__/base.cpython-311.pyc +0 -0
  22. graphstack/compact/__pycache__/generic.cpython-311.pyc +0 -0
  23. graphstack/compact/__pycache__/git.cpython-311.pyc +0 -0
  24. graphstack/compact/__pycache__/registry.cpython-311.pyc +0 -0
  25. graphstack/compact/base.py +115 -0
  26. graphstack/compact/generic.py +90 -0
  27. graphstack/compact/git.py +167 -0
  28. graphstack/compact/registry.py +47 -0
  29. graphstack/constants.py +38 -0
  30. graphstack/gate.py +429 -0
  31. graphstack/graph.py +143 -0
  32. graphstack/hook.py +144 -0
  33. graphstack/init_cmd.py +113 -0
  34. graphstack/installer.py +366 -0
  35. graphstack/platform_utils.py +127 -0
  36. graphstack/run.py +103 -0
  37. graphstack/state.py +117 -0
  38. graphstack/tests/__init__.py +0 -0
  39. graphstack/tests/conftest.py +30 -0
  40. graphstack/tests/test_assets.py +35 -0
  41. graphstack/tests/test_board.py +166 -0
  42. graphstack/tests/test_compact.py +93 -0
  43. graphstack/tests/test_gate.py +406 -0
  44. graphstack/tests/test_graph.py +60 -0
  45. graphstack/tests/test_hook.py +57 -0
  46. graphstack/tests/test_init.py +58 -0
  47. graphstack/tests/test_installer.py +73 -0
  48. graphstack/tests/test_platform_utils.py +69 -0
  49. graphstack/tests/test_state.py +56 -0
  50. graphstack/tests/test_validate.py +204 -0
  51. graphstack/validate.py +469 -0
  52. mertcapkin_graphstack-4.5.1.dist-info/METADATA +720 -0
  53. mertcapkin_graphstack-4.5.1.dist-info/RECORD +57 -0
  54. mertcapkin_graphstack-4.5.1.dist-info/WHEEL +5 -0
  55. mertcapkin_graphstack-4.5.1.dist-info/entry_points.txt +2 -0
  56. mertcapkin_graphstack-4.5.1.dist-info/licenses/LICENSE +21 -0
  57. 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
+
@@ -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:]))