eai-supcore 0.5.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.
- eai_supcore/__init__.py +3 -0
- eai_supcore/bootstrap.py +269 -0
- eai_supcore/data/__init__.py +1 -0
- eai_supcore/data/consumer/esysrepo.manifest.xml +7 -0
- eai_supcore/data/consumer/gitignore +18 -0
- eai_supcore/data/consumer/gitlint +11 -0
- eai_supcore/data/consumer/pre-commit-config.yaml +30 -0
- eai_supcore/data/consumer/scripts/setup-dev.py +84 -0
- eai_supcore/data/extensions/cmake/cmake-format.yaml +11 -0
- eai_supcore/data/extensions/cmake/pre-commit-snippet.yaml +7 -0
- eai_supcore/doctor_rails.py +64 -0
- eai_supcore/extension_cli.py +161 -0
- eai_supcore/extensions.py +267 -0
- eai_supcore/git_ops.py +107 -0
- eai_supcore/io_util.py +14 -0
- eai_supcore/paths.py +22 -0
- eai_supcore/templates.py +70 -0
- eai_supcore-0.5.0.dist-info/METADATA +34 -0
- eai_supcore-0.5.0.dist-info/RECORD +21 -0
- eai_supcore-0.5.0.dist-info/WHEEL +5 -0
- eai_supcore-0.5.0.dist-info/top_level.txt +1 -0
eai_supcore/__init__.py
ADDED
eai_supcore/bootstrap.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Shared steady-state bootstrap steps for eai-supinit and eai-supmig."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from eai_supcore.git_ops import GitError, add_submodule, is_git_repository, submodule_exists
|
|
13
|
+
from eai_supcore.io_util import read_text, write_text
|
|
14
|
+
from eai_supcore.paths import (
|
|
15
|
+
AI_RULES_SUBMODULE,
|
|
16
|
+
CURSOR_RULES_DIR,
|
|
17
|
+
DEFAULT_AI_RULES_URL,
|
|
18
|
+
ESYSREPO_MANIFEST_NAME,
|
|
19
|
+
STEADY_STATE_DIRS,
|
|
20
|
+
TOOLS_LOCAL_GITIGNORE_ENTRY,
|
|
21
|
+
)
|
|
22
|
+
from eai_supcore.templates import resolve_consumer_templates, resolve_template_file
|
|
23
|
+
|
|
24
|
+
CONSUMER_GITIGNORE_ENTRIES = (
|
|
25
|
+
TOOLS_LOCAL_GITIGNORE_ENTRY,
|
|
26
|
+
".venv/",
|
|
27
|
+
"venv/",
|
|
28
|
+
"build/",
|
|
29
|
+
"cmake-build-*/",
|
|
30
|
+
"out/",
|
|
31
|
+
".idea/",
|
|
32
|
+
".vscode/",
|
|
33
|
+
"*.swp",
|
|
34
|
+
".DS_Store",
|
|
35
|
+
"Thumbs.db",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class SharedBootstrapResult:
|
|
41
|
+
created_dirs: list[str] = field(default_factory=list)
|
|
42
|
+
created_files: list[str] = field(default_factory=list)
|
|
43
|
+
submodule_added: bool = False
|
|
44
|
+
ai_rules_found: bool = False
|
|
45
|
+
cursor_rules_materialized: list[str] = field(default_factory=list)
|
|
46
|
+
warnings: list[str] = field(default_factory=list)
|
|
47
|
+
errors: list[str] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def ensure_steady_state_dirs(workspace: Path, result: SharedBootstrapResult) -> None:
|
|
51
|
+
"""Create steady-state layout dirs (src/, libs/, docs/adr/, prompts/)."""
|
|
52
|
+
for rel in STEADY_STATE_DIRS:
|
|
53
|
+
target = workspace / rel
|
|
54
|
+
if not target.exists():
|
|
55
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
result.created_dirs.append(rel.as_posix())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _merge_gitignore_entries(
|
|
60
|
+
workspace: Path, entries: tuple[str, ...], result: SharedBootstrapResult
|
|
61
|
+
) -> None:
|
|
62
|
+
gitignore = workspace / ".gitignore"
|
|
63
|
+
existing_lines: list[str] = []
|
|
64
|
+
if gitignore.is_file():
|
|
65
|
+
content = read_text(gitignore)
|
|
66
|
+
existing_lines = content.splitlines()
|
|
67
|
+
present = {line.strip() for line in existing_lines}
|
|
68
|
+
else:
|
|
69
|
+
content = ""
|
|
70
|
+
present = set()
|
|
71
|
+
|
|
72
|
+
missing = [e for e in entries if e not in present and e.rstrip("/") not in present]
|
|
73
|
+
if not missing:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
blocks: list[str] = []
|
|
77
|
+
if not gitignore.is_file():
|
|
78
|
+
blocks.append("# Generated / updated by eai-sup* bootstrap")
|
|
79
|
+
elif content and not content.endswith("\n"):
|
|
80
|
+
existing_lines.append("")
|
|
81
|
+
|
|
82
|
+
# Keep tools_local comment when adding that entry
|
|
83
|
+
for entry in missing:
|
|
84
|
+
if entry == TOOLS_LOCAL_GITIGNORE_ENTRY and "# eai-sup* local installs" not in "\n".join(
|
|
85
|
+
existing_lines
|
|
86
|
+
):
|
|
87
|
+
blocks.append("# eai-sup* local installs (ADR-0024)")
|
|
88
|
+
blocks.append(entry)
|
|
89
|
+
|
|
90
|
+
new_body = "\n".join([*existing_lines, *blocks]).rstrip() + "\n"
|
|
91
|
+
write_text(gitignore, new_body)
|
|
92
|
+
if ".gitignore" not in result.created_files:
|
|
93
|
+
result.created_files.append(".gitignore")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def ensure_tools_local_gitignore(workspace: Path, result: SharedBootstrapResult) -> None:
|
|
97
|
+
"""Ensure ``.tools_local/`` is listed in consumer ``.gitignore``."""
|
|
98
|
+
_merge_gitignore_entries(workspace, (TOOLS_LOCAL_GITIGNORE_ENTRY,), result)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def ensure_consumer_gitignore(workspace: Path, result: SharedBootstrapResult) -> None:
|
|
102
|
+
"""Merge language-agnostic consumer ``.gitignore`` entries."""
|
|
103
|
+
_merge_gitignore_entries(workspace, CONSUMER_GITIGNORE_ENTRIES, result)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _write_new_from_template(
|
|
107
|
+
workspace: Path,
|
|
108
|
+
dest_rel: str,
|
|
109
|
+
template_root: Path,
|
|
110
|
+
result: SharedBootstrapResult,
|
|
111
|
+
) -> None:
|
|
112
|
+
dest = workspace / dest_rel
|
|
113
|
+
if dest.exists():
|
|
114
|
+
return
|
|
115
|
+
src = resolve_template_file(template_root, dest_rel)
|
|
116
|
+
if src is None:
|
|
117
|
+
result.warnings.append(f"Template missing: {dest_rel}")
|
|
118
|
+
return
|
|
119
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
shutil.copy2(src, dest)
|
|
121
|
+
result.created_files.append(dest_rel.replace("\\", "/"))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def ensure_esysrepo_manifest(
|
|
125
|
+
workspace: Path, result: SharedBootstrapResult, *, template_root: Path
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Scaffold basic ``.esysrepo.manifest.xml`` once if missing."""
|
|
128
|
+
_write_new_from_template(workspace, ESYSREPO_MANIFEST_NAME, template_root, result)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def install_dev_hooks(workspace: Path, result: SharedBootstrapResult) -> None:
|
|
132
|
+
"""Run consumer ``scripts/setup-dev.py`` when Python + git are available (warnings on fail)."""
|
|
133
|
+
script = workspace / "scripts" / "setup-dev.py"
|
|
134
|
+
if not script.is_file():
|
|
135
|
+
result.warnings.append("scripts/setup-dev.py missing — skipped hook install")
|
|
136
|
+
return
|
|
137
|
+
if shutil.which("git") is None:
|
|
138
|
+
result.warnings.append("git not on PATH — skipped hook install")
|
|
139
|
+
return
|
|
140
|
+
py = Path(sys.executable) if sys.executable else None
|
|
141
|
+
if py is None or not py.is_file():
|
|
142
|
+
result.warnings.append("Python not available — skipped hook install")
|
|
143
|
+
return
|
|
144
|
+
try:
|
|
145
|
+
proc = subprocess.run(
|
|
146
|
+
[str(py), str(script)],
|
|
147
|
+
cwd=workspace,
|
|
148
|
+
capture_output=True,
|
|
149
|
+
text=True,
|
|
150
|
+
check=False,
|
|
151
|
+
env={**os.environ},
|
|
152
|
+
)
|
|
153
|
+
if proc.returncode != 0:
|
|
154
|
+
detail = (proc.stderr or proc.stdout or "").strip()
|
|
155
|
+
result.warnings.append(
|
|
156
|
+
f"setup-dev.py failed (exit {proc.returncode})" + (f": {detail}" if detail else "")
|
|
157
|
+
)
|
|
158
|
+
except OSError as exc:
|
|
159
|
+
result.warnings.append(f"setup-dev.py failed: {exc}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def scaffold_consumer_dev_rails(
|
|
163
|
+
workspace: Path,
|
|
164
|
+
result: SharedBootstrapResult | None = None,
|
|
165
|
+
*,
|
|
166
|
+
with_hooks: bool = True,
|
|
167
|
+
with_manifest: bool = True,
|
|
168
|
+
install_hooks: bool = False,
|
|
169
|
+
) -> SharedBootstrapResult:
|
|
170
|
+
"""Materialize language-agnostic consumer rails (hooks, gitignore, setup-dev, manifest)."""
|
|
171
|
+
if result is None:
|
|
172
|
+
result = SharedBootstrapResult()
|
|
173
|
+
try:
|
|
174
|
+
template_root = resolve_consumer_templates(workspace)
|
|
175
|
+
except FileNotFoundError as exc:
|
|
176
|
+
result.errors.append(str(exc))
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
ensure_consumer_gitignore(workspace, result)
|
|
180
|
+
|
|
181
|
+
if with_hooks:
|
|
182
|
+
_write_new_from_template(workspace, ".pre-commit-config.yaml", template_root, result)
|
|
183
|
+
_write_new_from_template(workspace, ".gitlint", template_root, result)
|
|
184
|
+
scripts_dir = workspace / "scripts"
|
|
185
|
+
if not scripts_dir.exists():
|
|
186
|
+
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
result.created_dirs.append("scripts")
|
|
188
|
+
_write_new_from_template(workspace, "scripts/setup-dev.py", template_root, result)
|
|
189
|
+
|
|
190
|
+
if with_manifest:
|
|
191
|
+
ensure_esysrepo_manifest(workspace, result, template_root=template_root)
|
|
192
|
+
|
|
193
|
+
if install_hooks and with_hooks:
|
|
194
|
+
install_dev_hooks(workspace, result)
|
|
195
|
+
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def mount_ai_rules(
|
|
200
|
+
workspace: Path,
|
|
201
|
+
result: SharedBootstrapResult,
|
|
202
|
+
*,
|
|
203
|
+
ai_rules_url: str = DEFAULT_AI_RULES_URL,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Mount ``tools/ai-rules`` as submodule or reuse an existing git checkout."""
|
|
206
|
+
ai_rules_path = workspace / AI_RULES_SUBMODULE
|
|
207
|
+
if ai_rules_path.is_dir() and is_git_repository(ai_rules_path):
|
|
208
|
+
result.ai_rules_found = True
|
|
209
|
+
return
|
|
210
|
+
if submodule_exists(workspace, AI_RULES_SUBMODULE):
|
|
211
|
+
result.warnings.append("tools/ai-rules submodule already configured")
|
|
212
|
+
return
|
|
213
|
+
if ai_rules_path.exists():
|
|
214
|
+
result.warnings.append(
|
|
215
|
+
"tools/ai-rules exists but is not a git repository; skipping submodule add"
|
|
216
|
+
)
|
|
217
|
+
return
|
|
218
|
+
try:
|
|
219
|
+
add_submodule(workspace, ai_rules_url, AI_RULES_SUBMODULE)
|
|
220
|
+
result.submodule_added = True
|
|
221
|
+
except GitError as exc:
|
|
222
|
+
result.errors.append(str(exc))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def materialize_cursor_rules(
|
|
226
|
+
workspace: Path,
|
|
227
|
+
result: SharedBootstrapResult,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Copy shared Cursor ``.mdc`` rules from ``tools/ai-rules/cursor/rules``."""
|
|
230
|
+
ai_rules_path = workspace / AI_RULES_SUBMODULE
|
|
231
|
+
shared_rules = ai_rules_path / "cursor" / "rules"
|
|
232
|
+
cursor_dir = workspace / CURSOR_RULES_DIR
|
|
233
|
+
if not shared_rules.is_dir():
|
|
234
|
+
result.warnings.append(
|
|
235
|
+
f"Shared Cursor rules not found at {shared_rules}; run with --with-ai-rules first"
|
|
236
|
+
)
|
|
237
|
+
return
|
|
238
|
+
cursor_dir.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
for src in sorted(shared_rules.glob("*.mdc")):
|
|
240
|
+
dest = cursor_dir / src.name
|
|
241
|
+
if dest.exists():
|
|
242
|
+
result.warnings.append(f"Skipped existing Cursor rule: {dest.name}")
|
|
243
|
+
continue
|
|
244
|
+
shutil.copy2(src, dest)
|
|
245
|
+
result.cursor_rules_materialized.append(dest.name)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def write_cursor_overlay(
|
|
249
|
+
workspace: Path,
|
|
250
|
+
result: SharedBootstrapResult,
|
|
251
|
+
*,
|
|
252
|
+
overlay_name: str,
|
|
253
|
+
content: str,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Write a local Cursor overlay if missing."""
|
|
256
|
+
cursor_dir = workspace / CURSOR_RULES_DIR
|
|
257
|
+
cursor_dir.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
overlay = cursor_dir / overlay_name
|
|
259
|
+
if overlay.exists():
|
|
260
|
+
return
|
|
261
|
+
write_text(overlay, content)
|
|
262
|
+
result.created_files.append(str(overlay.relative_to(workspace)).replace("\\", "/"))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def materialized_cursor_rule_names(workspace: Path) -> list[str]:
|
|
266
|
+
shared_rules = workspace / AI_RULES_SUBMODULE / "cursor" / "rules"
|
|
267
|
+
if not shared_rules.is_dir():
|
|
268
|
+
return []
|
|
269
|
+
return sorted(item.name for item in shared_rules.glob("*.mdc") if item.is_file())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Package data marker for bundled consumer / extension templates."""
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<esysrepo_manifest kind="embedded" schema-version="1.1">
|
|
3
|
+
<default revision="refs/heads/develop" sync-j="4" />
|
|
4
|
+
<location name="origin" addr="ssh://git@gitlab.com/YOUR_GROUP" alt_addr="https://gitlab.com/YOUR_GROUP">
|
|
5
|
+
<!-- Add repositories under src/ and libs/ as members are added -->
|
|
6
|
+
</location>
|
|
7
|
+
</esysrepo_manifest>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# eai-sup* local installs (ADR-0024)
|
|
2
|
+
.tools_local/
|
|
3
|
+
|
|
4
|
+
# Local Python environment (created by scripts/setup-dev.py)
|
|
5
|
+
.venv/
|
|
6
|
+
venv/
|
|
7
|
+
|
|
8
|
+
# Build artifacts
|
|
9
|
+
build/
|
|
10
|
+
cmake-build-*/
|
|
11
|
+
out/
|
|
12
|
+
|
|
13
|
+
# Editor / OS
|
|
14
|
+
.idea/
|
|
15
|
+
.vscode/
|
|
16
|
+
*.swp
|
|
17
|
+
.DS_Store
|
|
18
|
+
Thumbs.db
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[general]
|
|
2
|
+
ignore=body-is-missing
|
|
3
|
+
|
|
4
|
+
[title-max-length]
|
|
5
|
+
line-length=72
|
|
6
|
+
|
|
7
|
+
[title-match-regex]
|
|
8
|
+
regex=^(WIP: )?((adr|build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9._/-]+\))?(!)?): [A-Z0-9].+$
|
|
9
|
+
|
|
10
|
+
[body-max-line-length]
|
|
11
|
+
line-length=100
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
ci:
|
|
2
|
+
autoupdate_commit_msg: "chore: Update pre-commit hooks"
|
|
3
|
+
autofix_commit_msg: "style: Apply pre-commit fixes"
|
|
4
|
+
|
|
5
|
+
repos:
|
|
6
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
7
|
+
rev: v4.6.0
|
|
8
|
+
hooks:
|
|
9
|
+
- id: check-case-conflict
|
|
10
|
+
- id: check-merge-conflict
|
|
11
|
+
- id: check-yaml
|
|
12
|
+
- id: end-of-file-fixer
|
|
13
|
+
exclude: ^\.cursor/
|
|
14
|
+
- id: mixed-line-ending
|
|
15
|
+
args: [--fix=lf]
|
|
16
|
+
- id: trailing-whitespace
|
|
17
|
+
exclude: ^\.cursor/
|
|
18
|
+
|
|
19
|
+
- repo: https://github.com/Lucas-C/pre-commit-hooks
|
|
20
|
+
rev: v1.5.5
|
|
21
|
+
hooks:
|
|
22
|
+
- id: remove-tabs
|
|
23
|
+
files: "\\.(md|mdc|ya?ml)$"
|
|
24
|
+
|
|
25
|
+
- repo: https://github.com/jorisroovers/gitlint
|
|
26
|
+
rev: v0.19.1
|
|
27
|
+
hooks:
|
|
28
|
+
- id: gitlint
|
|
29
|
+
stages: [commit-msg]
|
|
30
|
+
args: ["--msg-filename"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create/use .venv, install pre-commit, register pre-commit + commit-msg hooks.
|
|
3
|
+
|
|
4
|
+
Usage (from repository root):
|
|
5
|
+
python scripts/setup-dev.py
|
|
6
|
+
python3 scripts/setup-dev.py
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
18
|
+
VENV_DIR = ROOT / ".venv"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def log(msg: str) -> None:
|
|
22
|
+
print(f"[setup-dev] {msg}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def die(msg: str) -> None:
|
|
26
|
+
print(f"[setup-dev] ERROR: {msg}", file=sys.stderr)
|
|
27
|
+
raise SystemExit(1)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def venv_python(venv_dir: Path) -> Path:
|
|
31
|
+
if sys.platform == "win32":
|
|
32
|
+
return venv_dir / "Scripts" / "python.exe"
|
|
33
|
+
return venv_dir / "bin" / "python"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_python() -> Path:
|
|
37
|
+
active = os.environ.get("VIRTUAL_ENV")
|
|
38
|
+
if active:
|
|
39
|
+
candidate = venv_python(Path(active))
|
|
40
|
+
if candidate.is_file():
|
|
41
|
+
return candidate
|
|
42
|
+
existing = venv_python(VENV_DIR)
|
|
43
|
+
if existing.is_file():
|
|
44
|
+
return existing
|
|
45
|
+
host = Path(sys.executable) if sys.executable else None
|
|
46
|
+
if host is None or not host.is_file():
|
|
47
|
+
for name in ("python3", "python"):
|
|
48
|
+
found = shutil.which(name)
|
|
49
|
+
if found:
|
|
50
|
+
host = Path(found)
|
|
51
|
+
break
|
|
52
|
+
if host is None or not host.is_file():
|
|
53
|
+
die("Python 3.11+ required")
|
|
54
|
+
log(f"Creating {VENV_DIR.name}/ ...")
|
|
55
|
+
subprocess.run([str(host), "-m", "venv", str(VENV_DIR)], cwd=ROOT, check=True)
|
|
56
|
+
py = venv_python(VENV_DIR)
|
|
57
|
+
if not py.is_file():
|
|
58
|
+
die(f"venv created but python missing at {py}")
|
|
59
|
+
return py
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> None:
|
|
63
|
+
os.chdir(ROOT)
|
|
64
|
+
if not (ROOT / ".pre-commit-config.yaml").is_file():
|
|
65
|
+
die(".pre-commit-config.yaml missing — run eai-supinit/eai-supmig bootstrap first")
|
|
66
|
+
if shutil.which("git") is None:
|
|
67
|
+
die("git required on PATH")
|
|
68
|
+
py = resolve_python()
|
|
69
|
+
log(f"Using {py}")
|
|
70
|
+
log("Installing pre-commit ...")
|
|
71
|
+
subprocess.run([str(py), "-m", "pip", "install", "--upgrade", "pip"], cwd=ROOT, check=False)
|
|
72
|
+
subprocess.run([str(py), "-m", "pip", "install", "pre-commit"], cwd=ROOT, check=True)
|
|
73
|
+
log("Installing pre-commit hooks (pre-commit + commit-msg/gitlint) ...")
|
|
74
|
+
subprocess.run([str(py), "-m", "pre_commit", "install"], cwd=ROOT, check=True)
|
|
75
|
+
subprocess.run(
|
|
76
|
+
[str(py), "-m", "pre_commit", "install", "--hook-type", "commit-msg"],
|
|
77
|
+
cwd=ROOT,
|
|
78
|
+
check=True,
|
|
79
|
+
)
|
|
80
|
+
log("Done. Re-run after clone or when .pre-commit-config.yaml changes.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
main()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# cmake-format defaults for eai multi-repo workspaces (extension: cmake)
|
|
2
|
+
# See https://cmake-format.readthedocs.io/
|
|
3
|
+
format:
|
|
4
|
+
line_width: 100
|
|
5
|
+
tab_size: 2
|
|
6
|
+
max_subgroups_hwrap: 2
|
|
7
|
+
max_pargs_hwrap: 6
|
|
8
|
+
dangle_parens: false
|
|
9
|
+
separate_ctrl_name_with_space: false
|
|
10
|
+
separate_fn_name_with_space: false
|
|
11
|
+
raise_body_indent: false
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Doctor checks shared across eai-sup* for consumer rails and extensions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from eai_supcore.extensions import status_extensions
|
|
8
|
+
from eai_supcore.io_util import read_text
|
|
9
|
+
from eai_supcore.paths import ESYSREPO_MANIFEST_NAME, TOOLS_LOCAL_GITIGNORE_ENTRY
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_rails_doctor_checks(report, workspace: Path) -> None:
|
|
13
|
+
"""Append language-agnostic rails checks to a doctor report (``add`` method)."""
|
|
14
|
+
pre_commit = workspace / ".pre-commit-config.yaml"
|
|
15
|
+
gitlint = workspace / ".gitlint"
|
|
16
|
+
setup_dev = workspace / "scripts" / "setup-dev.py"
|
|
17
|
+
manifest = workspace / ESYSREPO_MANIFEST_NAME
|
|
18
|
+
gitignore = workspace / ".gitignore"
|
|
19
|
+
|
|
20
|
+
report.add(
|
|
21
|
+
".pre-commit-config.yaml",
|
|
22
|
+
pre_commit.is_file(),
|
|
23
|
+
"run bootstrap (default --with-dev-hooks)" if not pre_commit.is_file() else str(pre_commit),
|
|
24
|
+
)
|
|
25
|
+
report.add(
|
|
26
|
+
".gitlint",
|
|
27
|
+
gitlint.is_file(),
|
|
28
|
+
"run bootstrap (default --with-dev-hooks)" if not gitlint.is_file() else str(gitlint),
|
|
29
|
+
)
|
|
30
|
+
report.add(
|
|
31
|
+
"scripts/setup-dev.py",
|
|
32
|
+
setup_dev.is_file(),
|
|
33
|
+
"run bootstrap" if not setup_dev.is_file() else str(setup_dev),
|
|
34
|
+
)
|
|
35
|
+
report.add(
|
|
36
|
+
ESYSREPO_MANIFEST_NAME,
|
|
37
|
+
manifest.is_file(),
|
|
38
|
+
"run bootstrap (default --with-esysrepo-manifest)"
|
|
39
|
+
if not manifest.is_file()
|
|
40
|
+
else str(manifest),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
tools_local_ok = False
|
|
44
|
+
detail = f"add {TOOLS_LOCAL_GITIGNORE_ENTRY} via bootstrap"
|
|
45
|
+
if gitignore.is_file():
|
|
46
|
+
lines = {line.strip() for line in read_text(gitignore).splitlines()}
|
|
47
|
+
tools_local_ok = (
|
|
48
|
+
TOOLS_LOCAL_GITIGNORE_ENTRY in lines or TOOLS_LOCAL_GITIGNORE_ENTRY.rstrip("/") in lines
|
|
49
|
+
)
|
|
50
|
+
detail = str(gitignore) if tools_local_ok else detail
|
|
51
|
+
report.add(".tools_local/ in .gitignore", tools_local_ok, detail)
|
|
52
|
+
|
|
53
|
+
for status in status_extensions(workspace):
|
|
54
|
+
if not status.recorded and not status.pre_commit_block_present:
|
|
55
|
+
continue
|
|
56
|
+
name = f"extension:{status.extension_id}"
|
|
57
|
+
if status.drift:
|
|
58
|
+
report.add(name, False, "; ".join(status.notes) or "drift detected")
|
|
59
|
+
else:
|
|
60
|
+
report.add(
|
|
61
|
+
name,
|
|
62
|
+
True,
|
|
63
|
+
"applied" if status.recorded else "hooks present (unrecorded)",
|
|
64
|
+
)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Typer command group for workspace extensions (shared wiring)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from eai_supcore.extensions import add_extension, list_extensions, status_extensions
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def attach_extension_commands(parent: typer.Typer, *, resolve_workspace) -> None:
|
|
17
|
+
"""Register both ``extension`` and ``ext`` groups on ``parent``.
|
|
18
|
+
|
|
19
|
+
``resolve_workspace`` is a callable ``(Path | None) -> Path``.
|
|
20
|
+
"""
|
|
21
|
+
console = Console(stderr=True)
|
|
22
|
+
|
|
23
|
+
def _make_group() -> typer.Typer:
|
|
24
|
+
group = typer.Typer(
|
|
25
|
+
help="Workspace extensions (e.g. cmake)",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@group.command("list")
|
|
30
|
+
def list_cmd(
|
|
31
|
+
workspace: Annotated[
|
|
32
|
+
Path | None,
|
|
33
|
+
typer.Option("--workspace", "-C", help="Workspace root"),
|
|
34
|
+
] = None,
|
|
35
|
+
json_output: Annotated[bool, typer.Option("--json")] = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""List available extensions and whether each is applied."""
|
|
38
|
+
root = resolve_workspace(workspace)
|
|
39
|
+
items = list_extensions(root)
|
|
40
|
+
if json_output:
|
|
41
|
+
typer.echo(
|
|
42
|
+
json.dumps(
|
|
43
|
+
[
|
|
44
|
+
{
|
|
45
|
+
"id": i.id,
|
|
46
|
+
"description": i.description,
|
|
47
|
+
"applied": i.applied,
|
|
48
|
+
}
|
|
49
|
+
for i in items
|
|
50
|
+
],
|
|
51
|
+
indent=2,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
return
|
|
55
|
+
table = Table(title="Workspace extensions")
|
|
56
|
+
table.add_column("id")
|
|
57
|
+
table.add_column("applied")
|
|
58
|
+
table.add_column("description")
|
|
59
|
+
for item in items:
|
|
60
|
+
table.add_row(item.id, "yes" if item.applied else "no", item.description)
|
|
61
|
+
console.print(table)
|
|
62
|
+
|
|
63
|
+
@group.command("add")
|
|
64
|
+
def add_cmd(
|
|
65
|
+
extension_id: Annotated[str, typer.Argument(help="Extension id (e.g. cmake)")],
|
|
66
|
+
workspace: Annotated[
|
|
67
|
+
Path | None,
|
|
68
|
+
typer.Option("--workspace", "-C", help="Workspace root"),
|
|
69
|
+
] = None,
|
|
70
|
+
force: Annotated[
|
|
71
|
+
bool, typer.Option("--force", help="Refresh managed snippets / config")
|
|
72
|
+
] = False,
|
|
73
|
+
json_output: Annotated[bool, typer.Option("--json")] = False,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Apply one workspace extension."""
|
|
76
|
+
root = resolve_workspace(workspace)
|
|
77
|
+
result = add_extension(root, extension_id, force=force)
|
|
78
|
+
if json_output:
|
|
79
|
+
typer.echo(
|
|
80
|
+
json.dumps(
|
|
81
|
+
{
|
|
82
|
+
"extension_id": result.extension_id,
|
|
83
|
+
"ok": result.ok,
|
|
84
|
+
"already_applied": result.already_applied,
|
|
85
|
+
"created_files": result.created_files,
|
|
86
|
+
"updated_files": result.updated_files,
|
|
87
|
+
"warnings": result.warnings,
|
|
88
|
+
"errors": result.errors,
|
|
89
|
+
},
|
|
90
|
+
indent=2,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
if result.already_applied:
|
|
95
|
+
console.print(
|
|
96
|
+
f"[green]Extension `{extension_id}` already applied[/green] "
|
|
97
|
+
"(use --force to refresh)"
|
|
98
|
+
)
|
|
99
|
+
elif result.ok:
|
|
100
|
+
console.print(f"[green]Applied extension[/green] `{extension_id}`")
|
|
101
|
+
for path in result.created_files:
|
|
102
|
+
console.print(f" created: {path}")
|
|
103
|
+
for path in result.updated_files:
|
|
104
|
+
console.print(f" updated: {path}")
|
|
105
|
+
for warning in result.warnings:
|
|
106
|
+
console.print(f"[yellow]Warning:[/yellow] {warning}")
|
|
107
|
+
for error in result.errors:
|
|
108
|
+
console.print(f"[red]Error:[/red] {error}")
|
|
109
|
+
if not result.ok:
|
|
110
|
+
raise typer.Exit(code=1)
|
|
111
|
+
|
|
112
|
+
@group.command("status")
|
|
113
|
+
def status_cmd(
|
|
114
|
+
workspace: Annotated[
|
|
115
|
+
Path | None,
|
|
116
|
+
typer.Option("--workspace", "-C", help="Workspace root"),
|
|
117
|
+
] = None,
|
|
118
|
+
json_output: Annotated[bool, typer.Option("--json")] = False,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Show applied extensions and drift."""
|
|
121
|
+
root = resolve_workspace(workspace)
|
|
122
|
+
rows = status_extensions(root)
|
|
123
|
+
if json_output:
|
|
124
|
+
typer.echo(
|
|
125
|
+
json.dumps(
|
|
126
|
+
[
|
|
127
|
+
{
|
|
128
|
+
"extension_id": r.extension_id,
|
|
129
|
+
"recorded": r.recorded,
|
|
130
|
+
"pre_commit_block_present": r.pre_commit_block_present,
|
|
131
|
+
"config_files_present": r.config_files_present,
|
|
132
|
+
"config_files_missing": r.config_files_missing,
|
|
133
|
+
"drift": r.drift,
|
|
134
|
+
"notes": r.notes,
|
|
135
|
+
}
|
|
136
|
+
for r in rows
|
|
137
|
+
],
|
|
138
|
+
indent=2,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
return
|
|
142
|
+
table = Table(title="Extension status")
|
|
143
|
+
table.add_column("id")
|
|
144
|
+
table.add_column("recorded")
|
|
145
|
+
table.add_column("hooks")
|
|
146
|
+
table.add_column("drift")
|
|
147
|
+
table.add_column("notes")
|
|
148
|
+
for row in rows:
|
|
149
|
+
table.add_row(
|
|
150
|
+
row.extension_id,
|
|
151
|
+
"yes" if row.recorded else "no",
|
|
152
|
+
"yes" if row.pre_commit_block_present else "no",
|
|
153
|
+
"yes" if row.drift else "no",
|
|
154
|
+
"; ".join(row.notes) if row.notes else "—",
|
|
155
|
+
)
|
|
156
|
+
console.print(table)
|
|
157
|
+
|
|
158
|
+
return group
|
|
159
|
+
|
|
160
|
+
parent.add_typer(_make_group(), name="extension")
|
|
161
|
+
parent.add_typer(_make_group(), name="ext")
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Workspace extension registry (cmake, …) shared by eai-sup* CLIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from eai_supcore.io_util import read_text, write_text
|
|
13
|
+
from eai_supcore.paths import EXTENSIONS_STATE_PATH
|
|
14
|
+
from eai_supcore.templates import resolve_extension_templates
|
|
15
|
+
|
|
16
|
+
BEGIN_MARK = "# BEGIN eai-sup extension: {id}"
|
|
17
|
+
END_MARK = "# END eai-sup extension: {id}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ExtensionInfo:
|
|
22
|
+
id: str
|
|
23
|
+
description: str
|
|
24
|
+
applied: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ExtensionApplyResult:
|
|
29
|
+
extension_id: str
|
|
30
|
+
created_files: list[str] = field(default_factory=list)
|
|
31
|
+
updated_files: list[str] = field(default_factory=list)
|
|
32
|
+
warnings: list[str] = field(default_factory=list)
|
|
33
|
+
errors: list[str] = field(default_factory=list)
|
|
34
|
+
already_applied: bool = False
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def ok(self) -> bool:
|
|
38
|
+
return not self.errors
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ExtensionStatus:
|
|
43
|
+
extension_id: str
|
|
44
|
+
recorded: bool
|
|
45
|
+
pre_commit_block_present: bool
|
|
46
|
+
config_files_present: list[str] = field(default_factory=list)
|
|
47
|
+
config_files_missing: list[str] = field(default_factory=list)
|
|
48
|
+
drift: bool = False
|
|
49
|
+
notes: list[str] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _now_iso() -> str:
|
|
53
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def extensions_state_path(workspace: Path) -> Path:
|
|
57
|
+
return workspace / EXTENSIONS_STATE_PATH
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_extensions_state(workspace: Path) -> dict:
|
|
61
|
+
path = extensions_state_path(workspace)
|
|
62
|
+
if not path.is_file():
|
|
63
|
+
return {"version": 1, "applied": {}}
|
|
64
|
+
data = yaml.safe_load(read_text(path)) or {}
|
|
65
|
+
if not isinstance(data, dict):
|
|
66
|
+
return {"version": 1, "applied": {}}
|
|
67
|
+
data.setdefault("version", 1)
|
|
68
|
+
data.setdefault("applied", {})
|
|
69
|
+
if not isinstance(data["applied"], dict):
|
|
70
|
+
data["applied"] = {}
|
|
71
|
+
return data
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save_extensions_state(workspace: Path, state: dict) -> None:
|
|
75
|
+
path = extensions_state_path(workspace)
|
|
76
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
write_text(path, yaml.safe_dump(state, sort_keys=False))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def record_extension_applied(workspace: Path, extension_id: str) -> None:
|
|
81
|
+
state = load_extensions_state(workspace)
|
|
82
|
+
state["applied"][extension_id] = {"applied_at": _now_iso()}
|
|
83
|
+
save_extensions_state(workspace, state)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_extension_recorded(workspace: Path, extension_id: str) -> bool:
|
|
87
|
+
return extension_id in load_extensions_state(workspace).get("applied", {})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def baseline_hooks_present(workspace: Path) -> bool:
|
|
91
|
+
return (workspace / ".pre-commit-config.yaml").is_file()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _managed_block_present(pre_commit_text: str, extension_id: str) -> bool:
|
|
95
|
+
begin = BEGIN_MARK.format(id=extension_id)
|
|
96
|
+
end = END_MARK.format(id=extension_id)
|
|
97
|
+
return begin in pre_commit_text and end in pre_commit_text
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _merge_pre_commit_snippet(
|
|
101
|
+
workspace: Path,
|
|
102
|
+
extension_id: str,
|
|
103
|
+
snippet: str,
|
|
104
|
+
*,
|
|
105
|
+
force: bool,
|
|
106
|
+
) -> tuple[bool, str | None]:
|
|
107
|
+
"""Return ``(changed, error)``."""
|
|
108
|
+
path = workspace / ".pre-commit-config.yaml"
|
|
109
|
+
if not path.is_file():
|
|
110
|
+
return False, "`.pre-commit-config.yaml` missing — run bootstrap first"
|
|
111
|
+
text = read_text(path)
|
|
112
|
+
begin = BEGIN_MARK.format(id=extension_id)
|
|
113
|
+
end = END_MARK.format(id=extension_id)
|
|
114
|
+
if _managed_block_present(text, extension_id):
|
|
115
|
+
if not force:
|
|
116
|
+
return False, None
|
|
117
|
+
start = text.index(begin)
|
|
118
|
+
stop = text.index(end) + len(end)
|
|
119
|
+
if stop < len(text) and text[stop] == "\n":
|
|
120
|
+
stop += 1
|
|
121
|
+
text = text[:start] + snippet.rstrip() + "\n" + text[stop:]
|
|
122
|
+
write_text(path, text if text.endswith("\n") else text + "\n")
|
|
123
|
+
return True, None
|
|
124
|
+
|
|
125
|
+
body = text.rstrip() + "\n\n" + snippet.rstrip() + "\n"
|
|
126
|
+
write_text(path, body)
|
|
127
|
+
return True, None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _apply_cmake(workspace: Path, *, force: bool = False) -> ExtensionApplyResult:
|
|
131
|
+
result = ExtensionApplyResult(extension_id="cmake")
|
|
132
|
+
if not baseline_hooks_present(workspace):
|
|
133
|
+
result.errors.append(
|
|
134
|
+
"Baseline rails missing (`.pre-commit-config.yaml`) — run "
|
|
135
|
+
"`eai-supinit bootstrap` or `eai-supmig bootstrap` first"
|
|
136
|
+
)
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
templates = resolve_extension_templates(workspace, "cmake")
|
|
141
|
+
except FileNotFoundError as exc:
|
|
142
|
+
result.errors.append(str(exc))
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
snippet_path = templates / "pre-commit-snippet.yaml"
|
|
146
|
+
config_src = templates / "cmake-format.yaml"
|
|
147
|
+
if not config_src.is_file():
|
|
148
|
+
config_src = templates / ".cmake-format.yaml"
|
|
149
|
+
if not snippet_path.is_file():
|
|
150
|
+
result.errors.append(f"Missing snippet template: {snippet_path}")
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
snippet = read_text(snippet_path)
|
|
154
|
+
begin = BEGIN_MARK.format(id="cmake")
|
|
155
|
+
end = END_MARK.format(id="cmake")
|
|
156
|
+
if begin not in snippet:
|
|
157
|
+
snippet = f"{begin}\n{snippet.rstrip()}\n{end}\n"
|
|
158
|
+
|
|
159
|
+
recorded = is_extension_recorded(workspace, "cmake")
|
|
160
|
+
pre_commit = workspace / ".pre-commit-config.yaml"
|
|
161
|
+
block_already = _managed_block_present(read_text(pre_commit), "cmake")
|
|
162
|
+
config_dest = workspace / ".cmake-format.yaml"
|
|
163
|
+
config_exists = config_dest.is_file()
|
|
164
|
+
|
|
165
|
+
if recorded and block_already and config_exists and not force:
|
|
166
|
+
result.already_applied = True
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
changed, err = _merge_pre_commit_snippet(workspace, "cmake", snippet, force=force)
|
|
170
|
+
if err:
|
|
171
|
+
result.errors.append(err)
|
|
172
|
+
return result
|
|
173
|
+
if changed:
|
|
174
|
+
result.updated_files.append(".pre-commit-config.yaml")
|
|
175
|
+
|
|
176
|
+
if config_src.is_file() and (not config_exists or force):
|
|
177
|
+
write_text(config_dest, read_text(config_src))
|
|
178
|
+
if not config_exists:
|
|
179
|
+
result.created_files.append(".cmake-format.yaml")
|
|
180
|
+
else:
|
|
181
|
+
result.updated_files.append(".cmake-format.yaml")
|
|
182
|
+
|
|
183
|
+
state_path = extensions_state_path(workspace)
|
|
184
|
+
state_existed = state_path.is_file()
|
|
185
|
+
record_extension_applied(workspace, "cmake")
|
|
186
|
+
rel_state = EXTENSIONS_STATE_PATH.as_posix()
|
|
187
|
+
if state_existed:
|
|
188
|
+
result.updated_files.append(rel_state)
|
|
189
|
+
else:
|
|
190
|
+
result.created_files.append(rel_state)
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _status_cmake(workspace: Path) -> ExtensionStatus:
|
|
195
|
+
recorded = is_extension_recorded(workspace, "cmake")
|
|
196
|
+
pre_commit = workspace / ".pre-commit-config.yaml"
|
|
197
|
+
block = False
|
|
198
|
+
if pre_commit.is_file():
|
|
199
|
+
block = _managed_block_present(read_text(pre_commit), "cmake")
|
|
200
|
+
config = workspace / ".cmake-format.yaml"
|
|
201
|
+
present = [".cmake-format.yaml"] if config.is_file() else []
|
|
202
|
+
missing = [] if config.is_file() else [".cmake-format.yaml"]
|
|
203
|
+
notes: list[str] = []
|
|
204
|
+
if recorded and not block:
|
|
205
|
+
notes.append("managed pre-commit block missing")
|
|
206
|
+
if recorded and missing:
|
|
207
|
+
notes.append("config file(s) missing: " + ", ".join(missing))
|
|
208
|
+
if block and not recorded:
|
|
209
|
+
notes.append("pre-commit block present but not recorded in extensions.yaml")
|
|
210
|
+
drift = bool(notes)
|
|
211
|
+
return ExtensionStatus(
|
|
212
|
+
extension_id="cmake",
|
|
213
|
+
recorded=recorded,
|
|
214
|
+
pre_commit_block_present=block,
|
|
215
|
+
config_files_present=present,
|
|
216
|
+
config_files_missing=missing,
|
|
217
|
+
drift=drift,
|
|
218
|
+
notes=notes,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True)
|
|
223
|
+
class _ExtensionSpec:
|
|
224
|
+
id: str
|
|
225
|
+
description: str
|
|
226
|
+
apply_force: Callable[[Path, bool], ExtensionApplyResult]
|
|
227
|
+
status: Callable[[Path], ExtensionStatus]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
REGISTRY: dict[str, _ExtensionSpec] = {
|
|
231
|
+
"cmake": _ExtensionSpec(
|
|
232
|
+
id="cmake",
|
|
233
|
+
description="CMake builds — cmake-format + cmake-lint pre-commit hooks",
|
|
234
|
+
apply_force=lambda ws, force: _apply_cmake(ws, force=force),
|
|
235
|
+
status=_status_cmake,
|
|
236
|
+
),
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def list_extensions(workspace: Path | None = None) -> list[ExtensionInfo]:
|
|
241
|
+
applied: dict = {}
|
|
242
|
+
if workspace is not None:
|
|
243
|
+
applied = load_extensions_state(workspace).get("applied", {})
|
|
244
|
+
return [
|
|
245
|
+
ExtensionInfo(
|
|
246
|
+
id=spec.id,
|
|
247
|
+
description=spec.description,
|
|
248
|
+
applied=spec.id in applied,
|
|
249
|
+
)
|
|
250
|
+
for spec in REGISTRY.values()
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def add_extension(
|
|
255
|
+
workspace: Path, extension_id: str, *, force: bool = False
|
|
256
|
+
) -> ExtensionApplyResult:
|
|
257
|
+
spec = REGISTRY.get(extension_id)
|
|
258
|
+
if spec is None:
|
|
259
|
+
result = ExtensionApplyResult(extension_id=extension_id)
|
|
260
|
+
known = ", ".join(sorted(REGISTRY)) or "(none)"
|
|
261
|
+
result.errors.append(f"Unknown extension {extension_id!r}. Known: {known}")
|
|
262
|
+
return result
|
|
263
|
+
return spec.apply_force(workspace, force)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def status_extensions(workspace: Path) -> list[ExtensionStatus]:
|
|
267
|
+
return [spec.status(workspace) for spec in REGISTRY.values()]
|
eai_supcore/git_ops.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Cross-platform Git subprocess helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from eai_supcore.paths import AI_RULES_SUBMODULE
|
|
9
|
+
|
|
10
|
+
AI_RULES_CHECKOUT_MANUAL = "manual_clone"
|
|
11
|
+
AI_RULES_CHECKOUT_SUBMODULE = "submodule"
|
|
12
|
+
AI_RULES_CHECKOUT_MISSING = "missing"
|
|
13
|
+
AI_RULES_CHECKOUT_NON_GIT = "non_git"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GitError(RuntimeError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_git(args: list[str], *, cwd: Path, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
21
|
+
cmd = ["git", *args]
|
|
22
|
+
try:
|
|
23
|
+
result = subprocess.run(
|
|
24
|
+
cmd,
|
|
25
|
+
cwd=cwd,
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
encoding="utf-8",
|
|
29
|
+
check=False,
|
|
30
|
+
)
|
|
31
|
+
except FileNotFoundError as exc:
|
|
32
|
+
raise GitError("git executable not found on PATH") from exc
|
|
33
|
+
if check and result.returncode != 0:
|
|
34
|
+
stderr = (result.stderr or "").strip()
|
|
35
|
+
raise GitError(f"git {' '.join(args)} failed: {stderr}")
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def git_output(args: list[str], *, cwd: Path) -> str:
|
|
40
|
+
result = run_git(args, cwd=cwd)
|
|
41
|
+
return (result.stdout or "").strip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def submodule_exists(workspace: Path, path: Path) -> bool:
|
|
45
|
+
gitmodules = workspace / ".gitmodules"
|
|
46
|
+
if not gitmodules.is_file():
|
|
47
|
+
return False
|
|
48
|
+
rel = path.as_posix()
|
|
49
|
+
content = gitmodules.read_text(encoding="utf-8", errors="replace")
|
|
50
|
+
return f"path = {rel}" in content or f"path = {path}" in content
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_git_repository(path: Path) -> bool:
|
|
54
|
+
if not path.is_dir():
|
|
55
|
+
return False
|
|
56
|
+
try:
|
|
57
|
+
git_output(["rev-parse", "--git-dir"], cwd=path)
|
|
58
|
+
except GitError:
|
|
59
|
+
return False
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def paths_with_pending_changes(workspace: Path, rel_paths: list[str]) -> list[str]:
|
|
64
|
+
"""Return workspace-relative paths with staged, modified, or untracked changes."""
|
|
65
|
+
if not rel_paths or not is_git_repository(workspace):
|
|
66
|
+
return []
|
|
67
|
+
try:
|
|
68
|
+
porcelain = git_output(["status", "--porcelain", "--", *rel_paths], cwd=workspace)
|
|
69
|
+
except GitError:
|
|
70
|
+
return []
|
|
71
|
+
if not porcelain:
|
|
72
|
+
return []
|
|
73
|
+
pending: set[str] = set()
|
|
74
|
+
for line in porcelain.splitlines():
|
|
75
|
+
if len(line) < 4:
|
|
76
|
+
continue
|
|
77
|
+
rel = line[3:].strip().replace("\\", "/")
|
|
78
|
+
if rel:
|
|
79
|
+
pending.add(rel)
|
|
80
|
+
return sorted(pending)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def commit_paths(workspace: Path, rel_paths: list[str], message: str) -> str:
|
|
84
|
+
"""Stage ``rel_paths`` and create a commit; return the new commit SHA."""
|
|
85
|
+
if not rel_paths:
|
|
86
|
+
raise GitError("no paths to commit")
|
|
87
|
+
run_git(["add", "--", *rel_paths], cwd=workspace)
|
|
88
|
+
run_git(["commit", "-m", message], cwd=workspace)
|
|
89
|
+
return git_output(["rev-parse", "HEAD"], cwd=workspace)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def classify_ai_rules_checkout(workspace: Path, ai_rules_path: Path) -> str:
|
|
93
|
+
"""Classify ``tools/ai-rules`` for reset and bootstrap messaging."""
|
|
94
|
+
if not ai_rules_path.exists():
|
|
95
|
+
return AI_RULES_CHECKOUT_MISSING
|
|
96
|
+
if submodule_exists(workspace, AI_RULES_SUBMODULE):
|
|
97
|
+
return AI_RULES_CHECKOUT_SUBMODULE
|
|
98
|
+
if is_git_repository(ai_rules_path):
|
|
99
|
+
return AI_RULES_CHECKOUT_MANUAL
|
|
100
|
+
return AI_RULES_CHECKOUT_NON_GIT
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def add_submodule(workspace: Path, url: str, path: Path) -> None:
|
|
104
|
+
rel = path.as_posix()
|
|
105
|
+
if submodule_exists(workspace, path):
|
|
106
|
+
return
|
|
107
|
+
run_git(["submodule", "add", url, rel], cwd=workspace)
|
eai_supcore/io_util.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""UTF-8 file I/O helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_text(path: Path) -> str:
|
|
9
|
+
return path.read_text(encoding="utf-8")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_text(path: Path, content: str) -> None:
|
|
13
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
path.write_text(content, encoding="utf-8", newline="\n")
|
eai_supcore/paths.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Shared path constants for the eai-sup* family."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
AI_RULES_SUBMODULE = Path("tools") / "ai-rules"
|
|
8
|
+
CURSOR_RULES_DIR = Path(".cursor") / "rules"
|
|
9
|
+
DEFAULT_AI_RULES_URL = "ssh://git@gitlab.com/libesys/ai-workflows/superbuild-workflow.git"
|
|
10
|
+
|
|
11
|
+
# Steady-state layout roles (repository-layout.md)
|
|
12
|
+
STEADY_STATE_DIRS = (
|
|
13
|
+
Path("src"),
|
|
14
|
+
Path("libs"),
|
|
15
|
+
Path("docs") / "adr",
|
|
16
|
+
Path("prompts"),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
TOOLS_LOCAL_GITIGNORE_ENTRY = ".tools_local/"
|
|
20
|
+
ESYSREPO_MANIFEST_NAME = ".esysrepo.manifest.xml"
|
|
21
|
+
# Shared eai-supcore tool state (extensions apply to the whole workspace)
|
|
22
|
+
EXTENSIONS_STATE_PATH = Path(".tools") / "esys" / "eai-supcore" / "extensions.yaml"
|
eai_supcore/templates.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Resolve consumer / extension template roots (method mount vs bundled)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from eai_supcore.paths import AI_RULES_SUBMODULE
|
|
9
|
+
|
|
10
|
+
# Dest path in workspace → template filename (no leading dots — setuptools package-data)
|
|
11
|
+
CONSUMER_TEMPLATE_FILES: dict[str, str] = {
|
|
12
|
+
".pre-commit-config.yaml": "pre-commit-config.yaml",
|
|
13
|
+
".gitlint": "gitlint",
|
|
14
|
+
".esysrepo.manifest.xml": "esysrepo.manifest.xml",
|
|
15
|
+
"scripts/setup-dev.py": "scripts/setup-dev.py",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def bundled_data_root() -> Path:
|
|
20
|
+
"""Return the filesystem path to ``eai_supcore.data``."""
|
|
21
|
+
root = resources.files("eai_supcore") / "data"
|
|
22
|
+
return Path(str(root))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_template_file(template_root: Path, dest_rel: str) -> Path | None:
|
|
26
|
+
"""Locate a template file by destination name or undotted package-data name."""
|
|
27
|
+
dest_rel = dest_rel.replace("\\", "/")
|
|
28
|
+
candidates = [dest_rel]
|
|
29
|
+
mapped = CONSUMER_TEMPLATE_FILES.get(dest_rel)
|
|
30
|
+
if mapped and mapped not in candidates:
|
|
31
|
+
candidates.append(mapped)
|
|
32
|
+
name = Path(dest_rel).name
|
|
33
|
+
if name.startswith("."):
|
|
34
|
+
parent = Path(dest_rel).parent
|
|
35
|
+
undotted = name[1:] if str(parent) == "." else f"{parent.as_posix()}/{name[1:]}"
|
|
36
|
+
if undotted not in candidates:
|
|
37
|
+
candidates.append(undotted)
|
|
38
|
+
for rel in candidates:
|
|
39
|
+
path = template_root / rel
|
|
40
|
+
if path.is_file():
|
|
41
|
+
return path
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _has_consumer_hooks_template(root: Path) -> bool:
|
|
46
|
+
return resolve_template_file(root, ".pre-commit-config.yaml") is not None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_consumer_templates(workspace: Path) -> Path:
|
|
50
|
+
"""Prefer ``tools/ai-rules/templates/consumer``; else bundled package data."""
|
|
51
|
+
mounted = workspace / AI_RULES_SUBMODULE / "templates" / "consumer"
|
|
52
|
+
if mounted.is_dir() and _has_consumer_hooks_template(mounted):
|
|
53
|
+
return mounted
|
|
54
|
+
bundled = bundled_data_root() / "consumer"
|
|
55
|
+
if not _has_consumer_hooks_template(bundled):
|
|
56
|
+
raise FileNotFoundError(
|
|
57
|
+
"Consumer templates not found (mount tools/ai-rules or reinstall eai-supcore)"
|
|
58
|
+
)
|
|
59
|
+
return bundled
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_extension_templates(workspace: Path, extension_id: str) -> Path:
|
|
63
|
+
"""Prefer method-tree extension templates; else bundled package data."""
|
|
64
|
+
mounted = workspace / AI_RULES_SUBMODULE / "templates" / "extensions" / extension_id
|
|
65
|
+
if mounted.is_dir():
|
|
66
|
+
return mounted
|
|
67
|
+
bundled = bundled_data_root() / "extensions" / extension_id
|
|
68
|
+
if not bundled.is_dir():
|
|
69
|
+
raise FileNotFoundError(f"Extension templates missing for id={extension_id!r}")
|
|
70
|
+
return bundled
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eai-supcore
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Shared steady-state bootstrap APIs for the eai-sup* CLI family
|
|
5
|
+
Author-email: Michel Gillet <michel.gillet@libesys.org>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://gitlab.com/libesys/ai-workflows/superbuild-workflow
|
|
8
|
+
Project-URL: Repository, https://gitlab.com/libesys/ai-workflows/superbuild-workflow.git
|
|
9
|
+
Keywords: superbuild,cmake,conan,bootstrap,cli
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: pyyaml>=6.0
|
|
20
|
+
Requires-Dist: typer>=0.12
|
|
21
|
+
Requires-Dist: rich>=13.7
|
|
22
|
+
|
|
23
|
+
# eai-supcore
|
|
24
|
+
|
|
25
|
+
Shared steady-state bootstrap and consumer-rails APIs for **`eai-supinit`** and **`eai-supmig`**.
|
|
26
|
+
|
|
27
|
+
Includes:
|
|
28
|
+
|
|
29
|
+
- Layout / AI-rules / Cursor helpers
|
|
30
|
+
- Language-agnostic consumer rails (`scaffold_consumer_dev_rails`)
|
|
31
|
+
- Workspace extensions registry (`cmake`, …) and shared `extension`/`ext` CLI wiring
|
|
32
|
+
|
|
33
|
+
See [docs/init-assistant.md](../../docs/init-assistant.md) and
|
|
34
|
+
[ADR-0002](../../docs/adr/0002-consumer-rails-and-workspace-extensions.md).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
eai_supcore/__init__.py,sha256=rSY1dlFbovl2W61BBPRUaJm05iarvtxkUPVfV2wnVyk,93
|
|
2
|
+
eai_supcore/bootstrap.py,sha256=CpqQwXNhkWRKCWzQUUZ__QlhQImIMddGFcczreCwOSM,9267
|
|
3
|
+
eai_supcore/doctor_rails.py,sha256=Geu4rP_bhIQBGbsoDityFZUsOD9dzmz72fLobIFlqaY,2357
|
|
4
|
+
eai_supcore/extension_cli.py,sha256=lnVP7YKPktDdGYWh7uutesNUAtkAoZlERHyNQn37JjE,6324
|
|
5
|
+
eai_supcore/extensions.py,sha256=8FuRrjsm1Wd9L-SccLiIXVfv6H3T0KgASgAu94Kh_pM,8812
|
|
6
|
+
eai_supcore/git_ops.py,sha256=srLGT3KlptAcVpB9q7u-HMfbSqPGEDG2hfL6CQjqdgo,3369
|
|
7
|
+
eai_supcore/io_util.py,sha256=9d96omb8fo9eDPBIxFRW5O6q8lPIYi81t0uVt2IrxGE,336
|
|
8
|
+
eai_supcore/paths.py,sha256=K2MUabor9Fw_AwiNNXZn-VT6TYQeP2ruZ23U9L6oGdk,713
|
|
9
|
+
eai_supcore/templates.py,sha256=8dI_KXwRpWdAQAIceK0bOoQzgTGkFVt9zn-M0l8AJEQ,2668
|
|
10
|
+
eai_supcore/data/__init__.py,sha256=CpPVFhqXLUUTQCA2G_zMrGeSAukMvZ8mRjiitXi2pAU,70
|
|
11
|
+
eai_supcore/data/consumer/esysrepo.manifest.xml,sha256=G5D64XqJWVRJA8baguh3zYzkh5z_npbYL9VTmK4609s,365
|
|
12
|
+
eai_supcore/data/consumer/gitignore,sha256=FhawrDyGw_CPPBgRn6wNdvzmL4Bh_P4drVRkJOu9bCc,229
|
|
13
|
+
eai_supcore/data/consumer/gitlint,sha256=_sKCZZyGat1Ne01MDRNG77PXnowtGj5zoPcpZZrJfmc,250
|
|
14
|
+
eai_supcore/data/consumer/pre-commit-config.yaml,sha256=0snq0YPUsCJpMsLvpWoWFtYc6V9JTY3DJ2iHGkSYpcI,772
|
|
15
|
+
eai_supcore/data/consumer/scripts/setup-dev.py,sha256=Uybtbf8gjlAsD64BXJhbBEDrmyJuamVNejVDpV2S1z8,2560
|
|
16
|
+
eai_supcore/data/extensions/cmake/cmake-format.yaml,sha256=-ri3N82xbfnPjFgNk02qiKECwNKTx-AkJGtaCcciYEI,328
|
|
17
|
+
eai_supcore/data/extensions/cmake/pre-commit-snippet.yaml,sha256=z1uxU9p4YPu7wJLIK-CAhUsXTUNnSldXFLsxYnGbGHY,204
|
|
18
|
+
eai_supcore-0.5.0.dist-info/METADATA,sha256=_Gk-f7ARjMbqvjm9dmUAhbUBoMmQ9Kl_PFmHOWlI1zE,1368
|
|
19
|
+
eai_supcore-0.5.0.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
|
|
20
|
+
eai_supcore-0.5.0.dist-info/top_level.txt,sha256=Pfm5UJULDM0tne4404L5azsrkC_BV2YfF0NHCKi0RrU,12
|
|
21
|
+
eai_supcore-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
eai_supcore
|