framework-py 0.1.0.dev0__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.
- framework/__init__.py +250 -0
- framework/__main__.py +89 -0
- framework/_assets/hooks/pre-commit +59 -0
- framework/_assets/init.sql +63 -0
- framework/_assets/schema/layer.v1.json +605 -0
- framework/_assets/schema/layers.v1.json +791 -0
- framework/_assets/schema/spec.v1.json +604 -0
- framework/_log.py +29 -0
- framework/_paths.py +45 -0
- framework/_secretservice.py +69 -0
- framework/_time.py +11 -0
- framework/_wincred.py +105 -0
- framework/_yaml.py +90 -0
- framework/ai.py +204 -0
- framework/bootstrap.py +206 -0
- framework/cache.py +335 -0
- framework/catalog.py +193 -0
- framework/cloud.py +899 -0
- framework/config_home.py +194 -0
- framework/connections.py +442 -0
- framework/console.py +41 -0
- framework/create.py +579 -0
- framework/data.py +519 -0
- framework/db.py +222 -0
- framework/dotenv.py +79 -0
- framework/errors.py +39 -0
- framework/global_config.py +238 -0
- framework/hooks.py +364 -0
- framework/integrity.py +22 -0
- framework/notebooks.py +384 -0
- framework/outputs.py +324 -0
- framework/packages.py +276 -0
- framework/project.py +52 -0
- framework/py.typed +0 -0
- framework/quarto.py +163 -0
- framework/scaffold.py +269 -0
- framework/scratch.py +108 -0
- framework/security.py +646 -0
- framework/settings.py +439 -0
- framework/spec/__init__.py +0 -0
- framework/spec/check.py +211 -0
- framework/spec/merge.py +195 -0
- framework/spec/resolve.py +254 -0
- framework/storage.py +516 -0
- framework/templates/defaults.yml +144 -0
- framework/templates/gitignore/course +120 -0
- framework/templates/gitignore/presentation +114 -0
- framework/templates/gitignore/project +108 -0
- framework/templates/gitignore/sensitive +139 -0
- framework/templates/skills/framework-data/SKILL.md +47 -0
- framework/templates/skills/framework-outputs/SKILL.md +68 -0
- framework/templates/skills/framework-packages/SKILL.md +33 -0
- framework/templates/skills/framework-sensitive-data/SKILL.md +38 -0
- framework/templates/skills/framework-workflow/SKILL.md +58 -0
- framework/templates/stubs/notebook-default.ipynb +34 -0
- framework/templates/stubs/notebook-default.qmd +43 -0
- framework/templates/stubs/notebook-minimal.qmd +15 -0
- framework/templates/stubs/notebook-revealjs.qmd +34 -0
- framework/templates/stubs/script-default.py +18 -0
- framework_py-0.1.0.dev0.dist-info/METADATA +111 -0
- framework_py-0.1.0.dev0.dist-info/RECORD +63 -0
- framework_py-0.1.0.dev0.dist-info/WHEEL +4 -0
- framework_py-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Linux Secret Service entries in the format R's keyring package uses.
|
|
2
|
+
|
|
3
|
+
R's keyring package looks up items by the libsecret schema
|
|
4
|
+
``com.rstudio.keyring.password`` (the ``xdg:schema`` attribute) and the
|
|
5
|
+
``service`` and ``username`` attributes. Python's keyring package writes
|
|
6
|
+
items without ``xdg:schema`` and with an extra ``application`` attribute, so
|
|
7
|
+
R and ``fw`` cannot see them, and its replace adds a second item beside
|
|
8
|
+
theirs. Framework writes and deletes these items directly, the same way
|
|
9
|
+
``fw`` does, so each token has exactly one item that every client reads.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
SCHEMA = "com.rstudio.keyring.password"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _attributes(service: str, username: str) -> dict[str, str]:
|
|
22
|
+
return {"service": service, "username": username}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@contextmanager
|
|
26
|
+
def _collection() -> Iterator[Any]:
|
|
27
|
+
import secretstorage
|
|
28
|
+
|
|
29
|
+
connection = secretstorage.dbus_init()
|
|
30
|
+
try:
|
|
31
|
+
collection = secretstorage.get_default_collection(connection)
|
|
32
|
+
if collection.is_locked():
|
|
33
|
+
collection.unlock()
|
|
34
|
+
yield collection
|
|
35
|
+
finally:
|
|
36
|
+
connection.close()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get(service: str, username: str) -> str | None:
|
|
40
|
+
"""The secret, preferring the item in R's schema when there are several."""
|
|
41
|
+
with _collection() as collection:
|
|
42
|
+
items = list(collection.search_items(_attributes(service, username)))
|
|
43
|
+
if not items:
|
|
44
|
+
return None
|
|
45
|
+
items.sort(key=lambda item: item.get_attributes().get("xdg:schema") != SCHEMA)
|
|
46
|
+
secret = items[0].get_secret()
|
|
47
|
+
return bytes(secret).decode("utf-8") or None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def set(service: str, username: str, secret: str) -> None:
|
|
51
|
+
"""Replace every matching item with one item in R's schema."""
|
|
52
|
+
attributes = _attributes(service, username)
|
|
53
|
+
with _collection() as collection:
|
|
54
|
+
for item in collection.search_items(attributes):
|
|
55
|
+
item.delete()
|
|
56
|
+
collection.create_item(
|
|
57
|
+
f"{service}:{username}",
|
|
58
|
+
{**attributes, "xdg:schema": SCHEMA},
|
|
59
|
+
secret.encode("utf-8"),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def delete(service: str, username: str) -> bool:
|
|
64
|
+
"""Delete every matching item. Returns whether there was one."""
|
|
65
|
+
with _collection() as collection:
|
|
66
|
+
items = list(collection.search_items(_attributes(service, username)))
|
|
67
|
+
for item in items:
|
|
68
|
+
item.delete()
|
|
69
|
+
return bool(items)
|
framework/_time.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def now() -> str:
|
|
7
|
+
"""The current local time as an ISO 8601 string, such as ``2026-10-30T14:05:09-0400``.
|
|
8
|
+
|
|
9
|
+
The format matches R's ``framework::now()``.
|
|
10
|
+
"""
|
|
11
|
+
return datetime.now().astimezone().strftime("%Y-%m-%dT%H:%M:%S%z")
|
framework/_wincred.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Windows Credential Manager entries in the format R's keyring package uses.
|
|
2
|
+
|
|
3
|
+
R's keyring package stores a generic credential with the target name
|
|
4
|
+
``:<service>:<username>`` and the secret as UTF-8 bytes. Python's keyring
|
|
5
|
+
package uses a different target and UTF-16 text, so Framework reads and
|
|
6
|
+
writes these entries directly, the same way ``fw`` does.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ctypes
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
CRED_TYPE_GENERIC = 1
|
|
16
|
+
CRED_PERSIST_LOCAL_MACHINE = 2
|
|
17
|
+
ERROR_NOT_FOUND = 1168
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def target(service: str, username: str) -> str:
|
|
21
|
+
return f":{service}:{username}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def decode(blob: bytes) -> str:
|
|
25
|
+
"""A secret blob as text: UTF-16LE when every second byte is zero, else UTF-8.
|
|
26
|
+
|
|
27
|
+
Tokens are ASCII, so a blob written by Python's keyring package (UTF-16)
|
|
28
|
+
has a zero in every odd position.
|
|
29
|
+
"""
|
|
30
|
+
if len(blob) >= 2 and len(blob) % 2 == 0 and all(b == 0 for b in blob[1::2]):
|
|
31
|
+
return blob.decode("utf-16-le")
|
|
32
|
+
return blob.decode("utf-8")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _api() -> tuple[Any, Any]: # pragma: no cover (Windows only)
|
|
36
|
+
from ctypes import wintypes
|
|
37
|
+
|
|
38
|
+
class FILETIME(ctypes.Structure):
|
|
39
|
+
_fields_ = [("dwLowDateTime", wintypes.DWORD), ("dwHighDateTime", wintypes.DWORD)]
|
|
40
|
+
|
|
41
|
+
class CREDENTIAL(ctypes.Structure):
|
|
42
|
+
_fields_ = [
|
|
43
|
+
("Flags", wintypes.DWORD),
|
|
44
|
+
("Type", wintypes.DWORD),
|
|
45
|
+
("TargetName", wintypes.LPWSTR),
|
|
46
|
+
("Comment", wintypes.LPWSTR),
|
|
47
|
+
("LastWritten", FILETIME),
|
|
48
|
+
("CredentialBlobSize", wintypes.DWORD),
|
|
49
|
+
("CredentialBlob", ctypes.POINTER(ctypes.c_ubyte)),
|
|
50
|
+
("Persist", wintypes.DWORD),
|
|
51
|
+
("AttributeCount", wintypes.DWORD),
|
|
52
|
+
("Attributes", ctypes.c_void_p),
|
|
53
|
+
("TargetAlias", wintypes.LPWSTR),
|
|
54
|
+
("UserName", wintypes.LPWSTR),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
advapi = ctypes.WinDLL("advapi32", use_last_error=True) # type: ignore[attr-defined]
|
|
58
|
+
advapi.CredReadW.argtypes = [
|
|
59
|
+
wintypes.LPCWSTR,
|
|
60
|
+
wintypes.DWORD,
|
|
61
|
+
wintypes.DWORD,
|
|
62
|
+
ctypes.POINTER(ctypes.POINTER(CREDENTIAL)),
|
|
63
|
+
]
|
|
64
|
+
advapi.CredReadW.restype = wintypes.BOOL
|
|
65
|
+
advapi.CredWriteW.argtypes = [ctypes.POINTER(CREDENTIAL), wintypes.DWORD]
|
|
66
|
+
advapi.CredWriteW.restype = wintypes.BOOL
|
|
67
|
+
advapi.CredDeleteW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD]
|
|
68
|
+
advapi.CredDeleteW.restype = wintypes.BOOL
|
|
69
|
+
advapi.CredFree.argtypes = [ctypes.c_void_p]
|
|
70
|
+
return advapi, CREDENTIAL
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get(service: str, username: str) -> str | None: # pragma: no cover (Windows only)
|
|
74
|
+
if sys.platform != "win32":
|
|
75
|
+
return None
|
|
76
|
+
advapi, credential = _api()
|
|
77
|
+
pointer = ctypes.POINTER(credential)()
|
|
78
|
+
if not advapi.CredReadW(target(service, username), CRED_TYPE_GENERIC, 0, ctypes.byref(pointer)):
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
entry = pointer.contents
|
|
82
|
+
blob = ctypes.string_at(entry.CredentialBlob, entry.CredentialBlobSize)
|
|
83
|
+
return decode(blob)
|
|
84
|
+
finally:
|
|
85
|
+
advapi.CredFree(pointer)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def set(service: str, username: str, secret: str) -> None: # pragma: no cover (Windows only)
|
|
89
|
+
advapi, credential = _api()
|
|
90
|
+
blob = secret.encode("utf-8")
|
|
91
|
+
buffer = (ctypes.c_ubyte * len(blob)).from_buffer_copy(blob)
|
|
92
|
+
entry = credential()
|
|
93
|
+
entry.Type = CRED_TYPE_GENERIC
|
|
94
|
+
entry.TargetName = target(service, username)
|
|
95
|
+
entry.CredentialBlobSize = len(blob)
|
|
96
|
+
entry.CredentialBlob = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))
|
|
97
|
+
entry.Persist = CRED_PERSIST_LOCAL_MACHINE
|
|
98
|
+
entry.UserName = username
|
|
99
|
+
if not advapi.CredWriteW(ctypes.byref(entry), 0):
|
|
100
|
+
raise OSError(ctypes.get_last_error(), "CredWriteW failed") # type: ignore[attr-defined]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def delete(service: str, username: str) -> bool: # pragma: no cover (Windows only)
|
|
104
|
+
advapi, _ = _api()
|
|
105
|
+
return bool(advapi.CredDeleteW(target(service, username), CRED_TYPE_GENERIC, 0))
|
framework/_yaml.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""YAML reading and writing with R-compatible behavior.
|
|
2
|
+
|
|
3
|
+
R's yaml package and PyYAML both follow YAML 1.1, so ``yes``/``no``,
|
|
4
|
+
``on``/``off``, octal numbers, and ``~`` read the same way. Two additions
|
|
5
|
+
match R:
|
|
6
|
+
|
|
7
|
+
- ``y``, ``Y``, ``n``, and ``N`` are booleans, as they are in R.
|
|
8
|
+
- The ``!expr`` tag is kept as the string ``"!expr <code>"`` for later
|
|
9
|
+
evaluation.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
_BOOL_TAG = "tag:yaml.org,2002:bool"
|
|
21
|
+
_SHORT_BOOLS = {"y": True, "n": False}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Loader(yaml.SafeLoader):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _expr(loader: yaml.SafeLoader, node: yaml.Node) -> str:
|
|
29
|
+
value = loader.construct_scalar(node) # type: ignore[arg-type]
|
|
30
|
+
return f"!expr {value}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _bool(loader: yaml.SafeLoader, node: yaml.Node) -> bool:
|
|
34
|
+
value = str(loader.construct_scalar(node)).lower() # type: ignore[arg-type]
|
|
35
|
+
if value in _SHORT_BOOLS:
|
|
36
|
+
return _SHORT_BOOLS[value]
|
|
37
|
+
return bool(loader.bool_values[value])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_Loader.add_constructor("!expr", _expr)
|
|
41
|
+
_Loader.add_constructor(_BOOL_TAG, _bool)
|
|
42
|
+
_Loader.add_implicit_resolver(_BOOL_TAG, re.compile(r"^(?:y|Y|n|N)$"), list("yYnN"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load(text: str) -> Any:
|
|
46
|
+
return yaml.load(text, Loader=_Loader)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read(path: Path) -> Any:
|
|
50
|
+
return load(path.read_text(encoding="utf-8"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _Dumper(yaml.SafeDumper):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _none(dumper: yaml.SafeDumper, _: None) -> yaml.Node:
|
|
58
|
+
# R writes NULL as "~".
|
|
59
|
+
return dumper.represent_scalar("tag:yaml.org,2002:null", "~")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Plain scalars R's yaml package reads as something other than a string:
|
|
63
|
+
# "." and ".na" become NA, and y/n are booleans. R quotes them when writing.
|
|
64
|
+
_R_SPECIAL = re.compile(r"^(?:\.|\.na|y|Y|n|N)$")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _str(dumper: yaml.SafeDumper, value: str) -> yaml.Node:
|
|
68
|
+
if _R_SPECIAL.match(value):
|
|
69
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", value, style="'")
|
|
70
|
+
return dumper.represent_str(value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_Dumper.add_representer(type(None), _none)
|
|
74
|
+
_Dumper.add_representer(str, _str)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def dump(data: Any) -> str:
|
|
78
|
+
return yaml.dump(
|
|
79
|
+
data,
|
|
80
|
+
Dumper=_Dumper,
|
|
81
|
+
default_flow_style=False,
|
|
82
|
+
sort_keys=False,
|
|
83
|
+
allow_unicode=True,
|
|
84
|
+
width=1_000_000,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def write(path: Path, data: Any) -> None:
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
path.write_text(dump(data), encoding="utf-8")
|
framework/ai.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""AI assistant files: AGENTS.md, assistant stubs, skills, and agent guards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from importlib.resources.abc import Traversable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from framework.spec.check import ASSISTANTS
|
|
12
|
+
|
|
13
|
+
AGENTS_DEFAULT = ".agents/skills"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _write(root: Path, relative: str, content: str) -> None:
|
|
17
|
+
target = root / relative
|
|
18
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
target.write_text(content, encoding="utf-8")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def assistant_targets(spec: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
23
|
+
"""Skills folders and stub files for the selected assistants.
|
|
24
|
+
|
|
25
|
+
With no assistants selected, skills go to ``.agents/skills`` only, the
|
|
26
|
+
folder more tools read.
|
|
27
|
+
"""
|
|
28
|
+
selected = [name for name in spec.get("ai", {}).get("assistants", {}) if name in ASSISTANTS]
|
|
29
|
+
if not selected:
|
|
30
|
+
return [AGENTS_DEFAULT], []
|
|
31
|
+
folders = list(dict.fromkeys(ASSISTANTS[name][0] for name in selected))
|
|
32
|
+
stubs = [stub for name in selected if (stub := ASSISTANTS[name][1]) is not None]
|
|
33
|
+
return folders, stubs
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def restricted_paths(spec: dict[str, Any]) -> list[str]:
|
|
37
|
+
"""Folders agents must not read: each configured path up to its ``private`` segment."""
|
|
38
|
+
paths = list((spec.get("directories") or {}).values())
|
|
39
|
+
paths += [entry.get("path") for entry in (spec.get("extra_directories") or {}).values()]
|
|
40
|
+
restricted = set()
|
|
41
|
+
for path in paths:
|
|
42
|
+
if not isinstance(path, str):
|
|
43
|
+
continue
|
|
44
|
+
parts = path.split("/")
|
|
45
|
+
if "private" in parts:
|
|
46
|
+
restricted.add("/".join(parts[: parts.index("private") + 1]) + "/")
|
|
47
|
+
return sorted(restricted)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def agents_md_stub(spec: dict[str, Any]) -> str:
|
|
51
|
+
lines = [
|
|
52
|
+
f"# {spec['name']}",
|
|
53
|
+
"",
|
|
54
|
+
"This is a Framework project (Python). Framework skills for this project are "
|
|
55
|
+
"installed for your AI assistant.",
|
|
56
|
+
"",
|
|
57
|
+
"Start every notebook and script with `import framework as fw` and `fw.scaffold()`. "
|
|
58
|
+
"It loads the project's settings, environment, and functions.",
|
|
59
|
+
]
|
|
60
|
+
if spec.get("security", {}).get("sensitive"):
|
|
61
|
+
folders = ", ".join(f"`{path}`" for path in restricted_paths(spec))
|
|
62
|
+
lines += [
|
|
63
|
+
"",
|
|
64
|
+
f"This project holds restricted data. Do not read or print files in {folders}. "
|
|
65
|
+
"Work from the data dictionary and simulated data instead.",
|
|
66
|
+
]
|
|
67
|
+
lines += ["", "## Project Notes", ""]
|
|
68
|
+
return "\n".join(lines) + "\n"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def pointer_stub(name: str) -> str:
|
|
72
|
+
return (
|
|
73
|
+
f"# {name}\n\n"
|
|
74
|
+
"Project instructions live in AGENTS.md (the canonical AI context file) and in\n"
|
|
75
|
+
"the project's skills. Read AGENTS.md before working here; do not duplicate its\n"
|
|
76
|
+
"content in this file.\n"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _shipped(name: str) -> Traversable:
|
|
81
|
+
return resources.files("framework").joinpath(f"templates/skills/{name}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _copy_tree(source: Traversable, root: Path, target: str) -> None:
|
|
85
|
+
for entry in source.iterdir():
|
|
86
|
+
if entry.is_dir():
|
|
87
|
+
_copy_tree(entry, root, f"{target}/{entry.name}")
|
|
88
|
+
else:
|
|
89
|
+
_write(root, f"{target}/{entry.name}", entry.read_text(encoding="utf-8"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def write_ai_files(root: Path, spec: dict[str, Any]) -> list[str]:
|
|
93
|
+
"""Write AGENTS.md, assistant stubs, skills, and (for sensitive projects) agent guards."""
|
|
94
|
+
written = []
|
|
95
|
+
folders, stubs = assistant_targets(spec)
|
|
96
|
+
files = spec.get("files") or {}
|
|
97
|
+
|
|
98
|
+
if spec.get("ai", {}).get("agents_md") == "stub" and "AGENTS.md" not in files:
|
|
99
|
+
_write(root, "AGENTS.md", agents_md_stub(spec))
|
|
100
|
+
written.append("AGENTS.md")
|
|
101
|
+
for stub in stubs:
|
|
102
|
+
_write(root, stub, "@AGENTS.md\n" if stub == "CLAUDE.md" else pointer_stub(spec["name"]))
|
|
103
|
+
written.append(stub)
|
|
104
|
+
|
|
105
|
+
for name, entry in (spec.get("skills") or {}).items():
|
|
106
|
+
for folder in folders:
|
|
107
|
+
target = f"{folder}/{name}"
|
|
108
|
+
if entry.get("package") is True:
|
|
109
|
+
_copy_tree(_shipped(name), root, target)
|
|
110
|
+
else:
|
|
111
|
+
_write(root, f"{target}/SKILL.md", entry["content"])
|
|
112
|
+
for relative, content in (entry.get("files") or {}).items():
|
|
113
|
+
_write(root, f"{target}/{relative}", content)
|
|
114
|
+
written.append(target)
|
|
115
|
+
|
|
116
|
+
if spec.get("security", {}).get("sensitive"):
|
|
117
|
+
guard = write_agent_guards(root, spec)
|
|
118
|
+
if guard:
|
|
119
|
+
written.append(guard)
|
|
120
|
+
return written
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def write_agent_guards(root: Path, spec: dict[str, Any]) -> str | None:
|
|
124
|
+
"""Keep Claude Code out of restricted folders in a sensitive project.
|
|
125
|
+
|
|
126
|
+
Read and Edit deny rules block Claude Code's file tools, and the sandbox's
|
|
127
|
+
``denyRead`` also blocks subprocesses such as ``python``. The sandbox fails
|
|
128
|
+
closed when it is unavailable. The rules match the R package's.
|
|
129
|
+
"""
|
|
130
|
+
if "claude" not in (spec.get("ai") or {}).get("assistants", {}):
|
|
131
|
+
return None
|
|
132
|
+
restricted = [path.rstrip("/") for path in restricted_paths(spec)]
|
|
133
|
+
deny = [rule for path in restricted for rule in (f"Read(/{path}/**)", f"Edit(/{path}/**)")]
|
|
134
|
+
deny.append("Read(/.env)")
|
|
135
|
+
sandbox_deny = [f"./{path}" for path in restricted] + ["./.env"]
|
|
136
|
+
|
|
137
|
+
relative = ".claude/settings.json"
|
|
138
|
+
path = root / relative
|
|
139
|
+
settings: dict[str, Any] = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
140
|
+
permissions = settings.setdefault("permissions", {})
|
|
141
|
+
permissions["deny"] = list(dict.fromkeys([*permissions.get("deny", []), *deny]))
|
|
142
|
+
sandbox = settings.setdefault("sandbox", {})
|
|
143
|
+
sandbox["enabled"] = True
|
|
144
|
+
sandbox["failIfUnavailable"] = True
|
|
145
|
+
filesystem = sandbox.setdefault("filesystem", {})
|
|
146
|
+
filesystem["denyRead"] = list(dict.fromkeys([*filesystem.get("denyRead", []), *sandbox_deny]))
|
|
147
|
+
_write(root, relative, json.dumps(settings, indent=2) + "\n")
|
|
148
|
+
return relative
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _installed_spec(root: Path) -> dict[str, Any]:
|
|
152
|
+
"""The spec a project was created from, or one rebuilt from its settings."""
|
|
153
|
+
path = root / ".framework" / "spec.json"
|
|
154
|
+
if path.is_file():
|
|
155
|
+
try:
|
|
156
|
+
spec = json.loads(path.read_text(encoding="utf-8"))
|
|
157
|
+
except json.JSONDecodeError:
|
|
158
|
+
spec = None
|
|
159
|
+
if isinstance(spec, dict):
|
|
160
|
+
return spec
|
|
161
|
+
from framework.settings import settings_read
|
|
162
|
+
from framework.spec.resolve import TYPES, package_defaults
|
|
163
|
+
|
|
164
|
+
config = settings_read(root / "settings.yml") if (root / "settings.yml").is_file() else {}
|
|
165
|
+
ai = config.get("ai")
|
|
166
|
+
assistants = (ai.get("assistants") if isinstance(ai, dict) else None) or []
|
|
167
|
+
type = config.get("project_type")
|
|
168
|
+
return {
|
|
169
|
+
"ai": {"assistants": {name: True for name in assistants}},
|
|
170
|
+
"skills": package_defaults(type if type in TYPES else "project")["skills"],
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def ai_skills_update(project_path: str | Path = ".") -> list[str]:
|
|
175
|
+
"""Replace the project's Framework skills with the versions in this package.
|
|
176
|
+
|
|
177
|
+
Only skills that ship with Framework are updated. Skills from a blueprint
|
|
178
|
+
or facet, and skills you wrote, are left alone. The skills and assistants
|
|
179
|
+
come from ``.framework/spec.json``, or from the settings for projects
|
|
180
|
+
without one.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
The skill folders written.
|
|
184
|
+
"""
|
|
185
|
+
from framework._log import inform, show_messages
|
|
186
|
+
|
|
187
|
+
show_messages()
|
|
188
|
+
root = Path(project_path)
|
|
189
|
+
spec = _installed_spec(root)
|
|
190
|
+
folders, _ = assistant_targets(spec)
|
|
191
|
+
updated = []
|
|
192
|
+
for name, entry in (spec.get("skills") or {}).items():
|
|
193
|
+
if not (isinstance(entry, dict) and entry.get("package") is True):
|
|
194
|
+
continue
|
|
195
|
+
if not _shipped(name).is_dir():
|
|
196
|
+
continue
|
|
197
|
+
for folder in folders:
|
|
198
|
+
_copy_tree(_shipped(name), root, f"{folder}/{name}")
|
|
199
|
+
updated.append(f"{folder}/{name}")
|
|
200
|
+
if updated:
|
|
201
|
+
inform(f"✓ Updated {len(updated)} Framework skill(s)")
|
|
202
|
+
else:
|
|
203
|
+
inform("No Framework skills to update")
|
|
204
|
+
return updated
|
framework/bootstrap.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""``bootstrap()``: prepare a project after ``git clone``.
|
|
2
|
+
|
|
3
|
+
Every step is safe to repeat and none contacts the cloud:
|
|
4
|
+
|
|
5
|
+
1. Make sure ``.gitignore`` ignores ``.env``.
|
|
6
|
+
2. Create ``.env`` from ``.env.example`` when it is missing, and list
|
|
7
|
+
variables that are still empty.
|
|
8
|
+
3. Create the project folders, which git does not carry when they are empty
|
|
9
|
+
or ignored.
|
|
10
|
+
4. Create ``framework.db``, or add tables a newer version needs.
|
|
11
|
+
5. Install the packages from ``uv.lock`` (``uv sync``).
|
|
12
|
+
6. Install the git hooks enabled in the settings.
|
|
13
|
+
|
|
14
|
+
R's ``bootstrap()`` follows the same contract, so ``fw bootstrap`` can call
|
|
15
|
+
either language.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import getpass
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from framework import db, dotenv
|
|
30
|
+
from framework._log import inform, show_messages
|
|
31
|
+
from framework._paths import in_root
|
|
32
|
+
from framework.create import ensure_secrets_ignored
|
|
33
|
+
from framework.errors import FrameworkError
|
|
34
|
+
from framework.hooks import git_hooks_install
|
|
35
|
+
from framework.project import _settings_in
|
|
36
|
+
from framework.settings import settings_read
|
|
37
|
+
from framework.spec.check import spec_check
|
|
38
|
+
|
|
39
|
+
_SECRET_NAME = re.compile(
|
|
40
|
+
r"PASSWORD|SECRET|TOKEN|ACCESS_KEY|API_KEY|PRIVATE_KEY|CREDENTIAL", re.IGNORECASE
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class BootstrapResult:
|
|
46
|
+
"""What ``bootstrap()`` did, and what is left for you.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
root: The project folder.
|
|
50
|
+
done: The steps that changed something.
|
|
51
|
+
todo: Things to do by hand, such as filling in empty secrets.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
root: Path
|
|
55
|
+
done: list[str] = field(default_factory=list)
|
|
56
|
+
todo: list[str] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _check_spec(root: Path) -> None:
|
|
60
|
+
path = root / ".framework" / "spec.json"
|
|
61
|
+
if not path.is_file():
|
|
62
|
+
return
|
|
63
|
+
try:
|
|
64
|
+
spec = json.loads(path.read_text(encoding="utf-8"))
|
|
65
|
+
except json.JSONDecodeError as error:
|
|
66
|
+
raise FrameworkError(f".framework/spec.json is not valid JSON: {error}") from error
|
|
67
|
+
problems = spec_check(spec) if isinstance(spec, dict) else ["it is not a JSON object"]
|
|
68
|
+
if problems:
|
|
69
|
+
raise FrameworkError(
|
|
70
|
+
".framework/spec.json is invalid: " + "; ".join(str(p) for p in problems)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _prepare_env(root: Path, interactive: bool, result: BootstrapResult) -> None:
|
|
75
|
+
env = root / ".env"
|
|
76
|
+
example = root / ".env.example"
|
|
77
|
+
if not env.exists() and example.is_file():
|
|
78
|
+
shutil.copyfile(example, env)
|
|
79
|
+
result.done.append("Created .env from .env.example")
|
|
80
|
+
if not env.is_file():
|
|
81
|
+
return
|
|
82
|
+
empty = [key for key, value in dotenv.read(env).items() if value == ""]
|
|
83
|
+
if interactive and empty:
|
|
84
|
+
values: dict[str, str] = {}
|
|
85
|
+
for key in empty:
|
|
86
|
+
ask = getpass.getpass if _SECRET_NAME.search(key) else input
|
|
87
|
+
answer = ask(f"{key} (leave empty to skip): ").strip()
|
|
88
|
+
if answer:
|
|
89
|
+
values[key] = answer
|
|
90
|
+
if values:
|
|
91
|
+
_fill_env(env, values)
|
|
92
|
+
result.done.append(f"Filled in {', '.join(values)} in .env")
|
|
93
|
+
empty = [key for key in empty if key not in values]
|
|
94
|
+
if empty:
|
|
95
|
+
result.todo.append(f"Fill in .env: {', '.join(empty)}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _fill_env(path: Path, values: dict[str, str]) -> None:
|
|
99
|
+
"""Set empty ``KEY=`` lines in a .env file, keeping the other lines as they are."""
|
|
100
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
101
|
+
for number, line in enumerate(lines):
|
|
102
|
+
match = re.match(r"^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*$", line)
|
|
103
|
+
if match and match.group(2) in values:
|
|
104
|
+
value = values[match.group(2)]
|
|
105
|
+
quoted = json.dumps(value) if re.search(r"\s|#|\"|'", value) else value
|
|
106
|
+
lines[number] = f"{match.group(1)}{match.group(2)}={quoted}"
|
|
107
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _folders(config: dict[str, Any]) -> list[str]:
|
|
111
|
+
folders: list[str] = []
|
|
112
|
+
directories = config.get("directories")
|
|
113
|
+
if isinstance(directories, dict):
|
|
114
|
+
folders += [value for value in directories.values() if isinstance(value, str) and value]
|
|
115
|
+
extra = config.get("extra_directories")
|
|
116
|
+
if isinstance(extra, list):
|
|
117
|
+
folders += [
|
|
118
|
+
entry["path"] for entry in extra if isinstance(entry, dict) and entry.get("path")
|
|
119
|
+
]
|
|
120
|
+
return folders
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _create_folders(root: Path, config: dict[str, Any], result: BootstrapResult) -> None:
|
|
124
|
+
created = []
|
|
125
|
+
for folder in _folders(config):
|
|
126
|
+
if Path(folder).expanduser().is_absolute():
|
|
127
|
+
continue # Shared drives and other places outside the project.
|
|
128
|
+
path = in_root(folder, root)
|
|
129
|
+
if not path.exists():
|
|
130
|
+
path.mkdir(parents=True)
|
|
131
|
+
created.append(folder)
|
|
132
|
+
if created:
|
|
133
|
+
result.done.append(f"Created folders: {', '.join(created)}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _restore_packages(root: Path, result: BootstrapResult) -> None:
|
|
137
|
+
if not (root / "pyproject.toml").is_file():
|
|
138
|
+
return
|
|
139
|
+
uv = shutil.which("uv")
|
|
140
|
+
if uv is None:
|
|
141
|
+
raise FrameworkError(
|
|
142
|
+
"Package restore failed: uv is not installed. "
|
|
143
|
+
"Install it from https://docs.astral.sh/uv/ and run bootstrap again."
|
|
144
|
+
)
|
|
145
|
+
args = [uv, "sync", "--frozen"] if (root / "uv.lock").is_file() else [uv, "sync"]
|
|
146
|
+
completed = subprocess.run(args, cwd=root, check=False)
|
|
147
|
+
if completed.returncode != 0:
|
|
148
|
+
raise FrameworkError(
|
|
149
|
+
f"Package restore failed: {' '.join(args[1:])} exited with status "
|
|
150
|
+
f"{completed.returncode}"
|
|
151
|
+
)
|
|
152
|
+
result.done.append("Installed packages (uv sync)")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def bootstrap(
|
|
156
|
+
path: str | Path = ".", *, interactive: bool = False, packages: bool = True
|
|
157
|
+
) -> BootstrapResult:
|
|
158
|
+
"""Prepare a cloned project so it is ready to work in.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
path: The project folder.
|
|
162
|
+
interactive: Ask for the values of empty ``.env`` variables. Without
|
|
163
|
+
it, bootstrap never prompts and lists them as to-dos instead.
|
|
164
|
+
packages: Install the packages with uv.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
A ``BootstrapResult`` with the steps taken and the to-dos left.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
FrameworkError: The project cannot be prepared: there is no settings
|
|
171
|
+
file, the project is not a Python project, its spec is invalid,
|
|
172
|
+
or the packages could not be installed.
|
|
173
|
+
"""
|
|
174
|
+
show_messages()
|
|
175
|
+
root = Path(path).expanduser().resolve()
|
|
176
|
+
settings_file = _settings_in(root) if root.is_dir() else None
|
|
177
|
+
if settings_file is None:
|
|
178
|
+
raise FrameworkError(f"No settings.yml in {root}. Is this a Framework project?")
|
|
179
|
+
config = settings_read(settings_file)
|
|
180
|
+
language = config.get("language") or "r"
|
|
181
|
+
if language != "python":
|
|
182
|
+
raise FrameworkError(
|
|
183
|
+
f"This is a {language} project. Prepare it with that language's Framework "
|
|
184
|
+
"(in R: framework::bootstrap())."
|
|
185
|
+
)
|
|
186
|
+
_check_spec(root)
|
|
187
|
+
result = BootstrapResult(root=root)
|
|
188
|
+
|
|
189
|
+
if ensure_secrets_ignored(root):
|
|
190
|
+
result.done.append("Added .env to .gitignore")
|
|
191
|
+
_prepare_env(root, interactive, result)
|
|
192
|
+
_create_folders(root, config, result)
|
|
193
|
+
existed = db.db_path(root).exists()
|
|
194
|
+
db.ensure_db(root)
|
|
195
|
+
result.done.append("Updated framework.db" if existed else "Created framework.db")
|
|
196
|
+
if packages:
|
|
197
|
+
_restore_packages(root, result)
|
|
198
|
+
if (root / ".git").exists() and git_hooks_install(settings_file, verbose=False):
|
|
199
|
+
result.done.append("Installed git hooks")
|
|
200
|
+
|
|
201
|
+
for step in result.done:
|
|
202
|
+
inform(f"✓ {step}")
|
|
203
|
+
for item in result.todo:
|
|
204
|
+
inform(f"• To do: {item}")
|
|
205
|
+
inform(f"Project ready: {root}")
|
|
206
|
+
return result
|