agent-memory-cli 0.1.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.
- agent_memory/__init__.py +9 -0
- agent_memory/__main__.py +9 -0
- agent_memory/archive.py +359 -0
- agent_memory/cli.py +437 -0
- agent_memory/debrief.py +336 -0
- agent_memory/doctor.py +710 -0
- agent_memory/git_runner.py +68 -0
- agent_memory/home.py +246 -0
- agent_memory/layout.py +198 -0
- agent_memory/org.py +218 -0
- agent_memory/publication.py +433 -0
- agent_memory/setup/__init__.py +53 -0
- agent_memory/setup/claude.py +34 -0
- agent_memory/setup/codex.py +39 -0
- agent_memory/setup/common.py +1015 -0
- agent_memory/setup/legacy.py +67 -0
- agent_memory/startup.py +254 -0
- agent_memory/status.py +137 -0
- agent_memory/sync.py +649 -0
- agent_memory/templates/org-memory/decisions.md +5 -0
- agent_memory/templates/org-memory/recent.md +16 -0
- agent_memory/templates/org-memory/rules.md +5 -0
- agent_memory/templates/project-memory/decision_log.md +3 -0
- agent_memory/templates/project-memory/known_debt.md +8 -0
- agent_memory/templates/project-memory/open_threads.md +3 -0
- agent_memory/templates/project-memory/project_facts.md +4 -0
- agent_memory/templates/workflow/SKILL.md +42 -0
- agent_memory/workflow.py +287 -0
- agent_memory_cli-0.1.0.dist-info/METADATA +261 -0
- agent_memory_cli-0.1.0.dist-info/RECORD +33 -0
- agent_memory_cli-0.1.0.dist-info/WHEEL +4 -0
- agent_memory_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_memory_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Kiloloop
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Run git for the sync engine.
|
|
4
|
+
|
|
5
|
+
Every git invocation goes through one :class:`GitRunner`, so tests can record
|
|
6
|
+
calls or script answers without a repository, and the network verbs can carry
|
|
7
|
+
a timeout the default runner enforces. The runner never interprets git's
|
|
8
|
+
output; that is the engine's job.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional, Protocol, Sequence
|
|
17
|
+
|
|
18
|
+
#: Exit codes the default runner synthesizes when git itself did not run.
|
|
19
|
+
EXIT_TIMEOUT = 124
|
|
20
|
+
EXIT_NOT_FOUND = 127
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class GitResult:
|
|
25
|
+
"""What one git invocation returned."""
|
|
26
|
+
|
|
27
|
+
returncode: int
|
|
28
|
+
stdout: str = ""
|
|
29
|
+
stderr: str = ""
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def ok(self) -> bool:
|
|
33
|
+
return self.returncode == 0
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def timed_out(self) -> bool:
|
|
37
|
+
return self.returncode == EXIT_TIMEOUT
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def output(self) -> str:
|
|
41
|
+
"""stdout and stderr together, stripped: the text for a message."""
|
|
42
|
+
return "\n".join(part for part in (self.stdout, self.stderr) if part).strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GitRunner(Protocol):
|
|
46
|
+
"""Run ``git`` with ``args`` in ``cwd``; ``timeout`` is seconds or ``None``."""
|
|
47
|
+
|
|
48
|
+
def __call__(self, args: Sequence[str], *, cwd: Path, timeout: Optional[float] = None) -> GitResult: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_git(args: Sequence[str], *, cwd: Path, timeout: Optional[float] = None) -> GitResult:
|
|
52
|
+
"""The default runner: a subprocess, output captured, timeouts enforced."""
|
|
53
|
+
command = ["git", *args]
|
|
54
|
+
try:
|
|
55
|
+
completed = subprocess.run(
|
|
56
|
+
command,
|
|
57
|
+
cwd=str(cwd),
|
|
58
|
+
capture_output=True,
|
|
59
|
+
encoding="utf-8",
|
|
60
|
+
errors="surrogateescape",
|
|
61
|
+
check=False,
|
|
62
|
+
timeout=timeout,
|
|
63
|
+
)
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
return GitResult(EXIT_NOT_FOUND, "", "git: command not found")
|
|
66
|
+
except subprocess.TimeoutExpired:
|
|
67
|
+
return GitResult(EXIT_TIMEOUT, "", f"git {' '.join(args)}: timed out after {timeout:g}s")
|
|
68
|
+
return GitResult(completed.returncode, completed.stdout, completed.stderr)
|
agent_memory/home.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Kiloloop
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Find the memory home.
|
|
4
|
+
|
|
5
|
+
Resolution order; the first hit wins:
|
|
6
|
+
|
|
7
|
+
1. an explicit path (the ``--home`` flag);
|
|
8
|
+
2. ``$AGENT_MEMORY_HOME``;
|
|
9
|
+
3. ``$OACP_HOME``, recognized so existing homes keep working, never required;
|
|
10
|
+
4. the nearest ``.agent-memory.json`` binding, walking up from the working
|
|
11
|
+
directory: ``{"schema_version": 1, "home": "...", "project": "..."}``;
|
|
12
|
+
5. a workspace marker, walking up from the working directory: a symlink, or a
|
|
13
|
+
file named ``workspace.json``, whose real path has the shape
|
|
14
|
+
``<home>/projects/<name>/workspace.json``; the marker names the project too;
|
|
15
|
+
6. ``~/agent-memory``.
|
|
16
|
+
|
|
17
|
+
Any directory entry with the binding's name is the binding, and one that
|
|
18
|
+
is not a readable, well-formed v1 binding -- a dangling symlink, a directory,
|
|
19
|
+
unreadable or malformed JSON, an unknown ``schema_version``, no home -- is an
|
|
20
|
+
error, never a fall-through: silently picking a different store is worse
|
|
21
|
+
than stopping. So is an ancestor directory the process cannot inspect: it
|
|
22
|
+
might hold a binding, and only a directory known to hold none keeps the
|
|
23
|
+
walk going. The resolver only reads paths; it never asks whether any other
|
|
24
|
+
tool is installed.
|
|
25
|
+
|
|
26
|
+
The project is resolved separately (:func:`find_project`): when a flag or
|
|
27
|
+
an environment variable chose the home, the nearest binding or marker still
|
|
28
|
+
names the project, provided it binds the repository to that same home. A
|
|
29
|
+
binding for a different home lends nothing; the mismatch is reported.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Iterator, Mapping, Optional, Tuple
|
|
39
|
+
|
|
40
|
+
from .layout import PROJECTS_DIR
|
|
41
|
+
|
|
42
|
+
ENV_HOME = "AGENT_MEMORY_HOME"
|
|
43
|
+
ENV_COMPAT_HOME = "OACP_HOME"
|
|
44
|
+
BINDING_FILE = ".agent-memory.json"
|
|
45
|
+
BINDING_SCHEMA_VERSION = 1
|
|
46
|
+
WORKSPACE_FILE = "workspace.json"
|
|
47
|
+
DEFAULT_HOME = "~/agent-memory"
|
|
48
|
+
|
|
49
|
+
SOURCE_FLAG = "flag"
|
|
50
|
+
SOURCE_DEFAULT = "default"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HomeError(Exception):
|
|
54
|
+
"""The home could not be resolved safely."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class HomeResolution:
|
|
59
|
+
"""Where the home is and which rule chose it."""
|
|
60
|
+
|
|
61
|
+
path: Path
|
|
62
|
+
#: ``flag``, ``env:<NAME>``, ``binding:<file>``, ``marker:<file>`` or ``default``.
|
|
63
|
+
source: str
|
|
64
|
+
#: The project a binding or a marker named, when one of them chose the home.
|
|
65
|
+
project: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_home(
|
|
69
|
+
explicit: Optional[str] = None,
|
|
70
|
+
*,
|
|
71
|
+
env: Optional[Mapping[str, str]] = None,
|
|
72
|
+
cwd: Optional[Path] = None,
|
|
73
|
+
) -> HomeResolution:
|
|
74
|
+
"""Apply the resolution order and return the first hit.
|
|
75
|
+
|
|
76
|
+
``env`` and ``cwd`` default to the process environment and working
|
|
77
|
+
directory; tests pass their own to stay hermetic.
|
|
78
|
+
"""
|
|
79
|
+
environ: Mapping[str, str] = os.environ if env is None else env
|
|
80
|
+
if explicit is not None:
|
|
81
|
+
return HomeResolution(_expand(explicit), SOURCE_FLAG)
|
|
82
|
+
for name in (ENV_HOME, ENV_COMPAT_HOME):
|
|
83
|
+
value = environ.get(name)
|
|
84
|
+
if value:
|
|
85
|
+
return HomeResolution(_expand(value), f"env:{name}")
|
|
86
|
+
start = (Path.cwd() if cwd is None else Path(cwd)).expanduser().absolute()
|
|
87
|
+
for finder in (find_binding, find_workspace_marker):
|
|
88
|
+
found = finder(start)
|
|
89
|
+
if found is not None:
|
|
90
|
+
return found
|
|
91
|
+
return HomeResolution(_expand(DEFAULT_HOME), SOURCE_DEFAULT)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class ProjectResolution:
|
|
96
|
+
"""Which project the repository at hand belongs to, and how that was decided."""
|
|
97
|
+
|
|
98
|
+
project: Optional[str]
|
|
99
|
+
#: ``binding:<file>`` or ``marker:<file>``; ``None`` when no project was found.
|
|
100
|
+
source: Optional[str]
|
|
101
|
+
#: Why a binding or marker that was found did not name the project, when one was found.
|
|
102
|
+
note: Optional[str] = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def find_project(home: Path, start: Path) -> ProjectResolution:
|
|
106
|
+
"""The project the nearest binding or marker at or above ``start`` names for ``home``.
|
|
107
|
+
|
|
108
|
+
The same walk :func:`resolve_home` makes, with the same fail-closed binding
|
|
109
|
+
handling; the first binding or marker found decides. It names the project
|
|
110
|
+
only when it binds the repository to ``home`` itself: a binding for another
|
|
111
|
+
home says nothing about this one, and borrowing its project would list the
|
|
112
|
+
wrong files.
|
|
113
|
+
"""
|
|
114
|
+
start = Path(start).expanduser().absolute()
|
|
115
|
+
for finder in (find_binding, find_workspace_marker):
|
|
116
|
+
found = finder(start)
|
|
117
|
+
if found is None:
|
|
118
|
+
continue
|
|
119
|
+
if not _same_path(found.path, home):
|
|
120
|
+
return ProjectResolution(
|
|
121
|
+
None, None, f"{found.source} binds this repository to {found.path}, not to {home}; no project taken from it"
|
|
122
|
+
)
|
|
123
|
+
if found.project is None:
|
|
124
|
+
return ProjectResolution(None, None, f"{found.source} names no project")
|
|
125
|
+
return ProjectResolution(found.project, found.source)
|
|
126
|
+
return ProjectResolution(None, None)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _same_path(first: Path, second: Path) -> bool:
|
|
130
|
+
return os.path.realpath(Path(first).expanduser()) == os.path.realpath(Path(second).expanduser())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def find_binding(start: Path) -> Optional[HomeResolution]:
|
|
134
|
+
"""Load the nearest binding entry at or above ``start``; ``None`` when there is none.
|
|
135
|
+
|
|
136
|
+
Any directory entry with the binding's name counts, a dangling symlink or
|
|
137
|
+
a directory included: an entry that turns out unusable is an error from
|
|
138
|
+
:func:`load_binding`, never a reason to keep walking up.
|
|
139
|
+
"""
|
|
140
|
+
for directory in _ancestors(start):
|
|
141
|
+
candidate = directory / BINDING_FILE
|
|
142
|
+
try:
|
|
143
|
+
os.lstat(candidate)
|
|
144
|
+
except FileNotFoundError:
|
|
145
|
+
continue
|
|
146
|
+
except OSError as exc:
|
|
147
|
+
# An ancestor the process may not inspect could hold a binding; walking
|
|
148
|
+
# past it would silently pick another store. Absence and inability are
|
|
149
|
+
# different answers, and only the first keeps the walk going.
|
|
150
|
+
raise HomeError(f"{candidate}: cannot inspect the binding slot: {exc.strerror or exc}") from exc
|
|
151
|
+
return load_binding(candidate)
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_binding(path: Path) -> HomeResolution:
|
|
156
|
+
"""Parse one binding file; anything but a well-formed v1 binding raises :class:`HomeError`."""
|
|
157
|
+
if not path.is_file():
|
|
158
|
+
raise HomeError(f"{path}: binding {_describe_non_file(path)}")
|
|
159
|
+
try:
|
|
160
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
161
|
+
except (OSError, ValueError) as exc:
|
|
162
|
+
raise HomeError(f"{path}: cannot read binding: {exc}") from exc
|
|
163
|
+
if not isinstance(data, dict):
|
|
164
|
+
raise HomeError(f"{path}: binding must be a JSON object")
|
|
165
|
+
version = data.get("schema_version")
|
|
166
|
+
if isinstance(version, bool) or not isinstance(version, int) or version != BINDING_SCHEMA_VERSION:
|
|
167
|
+
raise HomeError(
|
|
168
|
+
f"{path}: unsupported binding schema_version {version!r} (this tool reads {BINDING_SCHEMA_VERSION})"
|
|
169
|
+
)
|
|
170
|
+
home = data.get("home")
|
|
171
|
+
if not isinstance(home, str) or not home:
|
|
172
|
+
raise HomeError(f"{path}: binding must name a non-empty 'home'")
|
|
173
|
+
project = data.get("project")
|
|
174
|
+
if project is not None and (not isinstance(project, str) or not project):
|
|
175
|
+
raise HomeError(f"{path}: binding 'project' must be a non-empty string when present")
|
|
176
|
+
home_path = _expand(home)
|
|
177
|
+
if not home_path.is_absolute():
|
|
178
|
+
home_path = path.parent / home_path
|
|
179
|
+
return HomeResolution(home_path, f"binding:{path}", project=project)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def find_workspace_marker(start: Path) -> Optional[HomeResolution]:
|
|
183
|
+
"""Find the nearest workspace marker at or above ``start``; ``None`` when there is none.
|
|
184
|
+
|
|
185
|
+
A marker is any symlink, or a file named ``workspace.json``, whose real
|
|
186
|
+
path has the shape ``<home>/projects/<name>/workspace.json``. The shape
|
|
187
|
+
is the guard: an editor's ``workspace.json`` in a repo root never sits
|
|
188
|
+
two levels below a ``projects`` directory. The symlink's own name is not
|
|
189
|
+
load-bearing, so a repo can call it whatever it likes.
|
|
190
|
+
"""
|
|
191
|
+
for directory in _ancestors(start):
|
|
192
|
+
for candidate in _marker_candidates(directory):
|
|
193
|
+
found = _home_from_workspace_file(candidate)
|
|
194
|
+
if found is not None:
|
|
195
|
+
home, project = found
|
|
196
|
+
return HomeResolution(home, f"marker:{candidate}", project=project)
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _marker_candidates(directory: Path) -> Iterator[Path]:
|
|
201
|
+
plain = directory / WORKSPACE_FILE
|
|
202
|
+
if plain.exists():
|
|
203
|
+
yield plain
|
|
204
|
+
try:
|
|
205
|
+
entries = sorted(directory.iterdir())
|
|
206
|
+
except OSError:
|
|
207
|
+
return
|
|
208
|
+
for entry in entries:
|
|
209
|
+
if entry.name != WORKSPACE_FILE and entry.is_symlink():
|
|
210
|
+
yield entry
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _home_from_workspace_file(path: Path) -> Optional[Tuple[Path, str]]:
|
|
214
|
+
"""``(home, project)`` when ``path`` resolves to ``<home>/projects/<project>/workspace.json``."""
|
|
215
|
+
try:
|
|
216
|
+
resolved = path.resolve(strict=True)
|
|
217
|
+
except (OSError, RuntimeError):
|
|
218
|
+
return None
|
|
219
|
+
if resolved.name != WORKSPACE_FILE or not resolved.is_file():
|
|
220
|
+
return None
|
|
221
|
+
projects = resolved.parent.parent
|
|
222
|
+
if projects.name != PROJECTS_DIR:
|
|
223
|
+
return None
|
|
224
|
+
return projects.parent, resolved.parent.name
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _ancestors(start: Path) -> Iterator[Path]:
|
|
228
|
+
yield start
|
|
229
|
+
yield from start.parents
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _describe_non_file(path: Path) -> str:
|
|
233
|
+
if path.is_dir():
|
|
234
|
+
return "is a directory, not a file"
|
|
235
|
+
if path.is_symlink():
|
|
236
|
+
return "is a symlink whose target is missing"
|
|
237
|
+
if not os.path.lexists(path):
|
|
238
|
+
return "does not exist"
|
|
239
|
+
return "is not a regular file"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _expand(value: str) -> Path:
|
|
243
|
+
try:
|
|
244
|
+
return Path(value).expanduser()
|
|
245
|
+
except RuntimeError as exc: # ``~user`` for a user the system cannot look up
|
|
246
|
+
raise HomeError(f"cannot expand {value!r}: {exc}") from exc
|
agent_memory/layout.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Kiloloop
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The memory-home layout, declared once.
|
|
4
|
+
|
|
5
|
+
Every other encoding of the layout is derived from the ``TIERS`` table
|
|
6
|
+
below: the sync allowlist written to a home's ``.gitignore``, the directories
|
|
7
|
+
a sync may stage, the per-path allow check, and the files and subdirectories
|
|
8
|
+
a fresh tier starts with. Change the table and every derivation follows;
|
|
9
|
+
nothing else in the package spells these names.
|
|
10
|
+
|
|
11
|
+
A home has two tiers::
|
|
12
|
+
|
|
13
|
+
<home>/
|
|
14
|
+
.gitignore the sync allowlist (gitignore_text)
|
|
15
|
+
.oacp-memory-repo present when the home syncs through git
|
|
16
|
+
org-memory/ cross-project memory
|
|
17
|
+
projects/<name>/memory/ per-project memory
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Callable, Iterator, List, Optional, Sequence, Tuple
|
|
26
|
+
|
|
27
|
+
#: Marks a home whose memory tiers sync through git. The name is a compatibility
|
|
28
|
+
#: contract with every existing home; keep it verbatim.
|
|
29
|
+
MARKER_FILE = ".oacp-memory-repo"
|
|
30
|
+
GITIGNORE_FILE = ".gitignore"
|
|
31
|
+
PROJECTS_DIR = "projects"
|
|
32
|
+
#: Local setup receipts, one per runtime and repository. Not a tier: the allowlist
|
|
33
|
+
#: never selects it, so it stays on the machine that wrote it.
|
|
34
|
+
SETUP_DIR = "setup"
|
|
35
|
+
WILDCARD = "*"
|
|
36
|
+
|
|
37
|
+
#: Top-level directories that must never sync, listed after every allow rule so
|
|
38
|
+
#: the deny wins even if the allowlist is widened later.
|
|
39
|
+
NEVER_SYNCED_DIRS: Tuple[str, ...] = ("keys",)
|
|
40
|
+
_NEVER_SYNCED_COMMENT = "# never sync private key material — explicit deny, wins over any future allowlist widening"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Tier:
|
|
45
|
+
"""One memory tier: where it lives under the home and what a fresh one holds."""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
#: Path pattern relative to the home; ``*`` stands for one project name.
|
|
49
|
+
pattern: str
|
|
50
|
+
#: Files a fresh tier directory starts with; their content is the scaffolding verbs' business.
|
|
51
|
+
files: Tuple[str, ...]
|
|
52
|
+
#: Subdirectories a fresh tier directory starts with.
|
|
53
|
+
dirs: Tuple[str, ...]
|
|
54
|
+
#: Subdirectories inside the tier that never sync.
|
|
55
|
+
unsynced: Tuple[str, ...] = ()
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def parts(self) -> Tuple[str, ...]:
|
|
59
|
+
return tuple(self.pattern.split("/"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
ORG = Tier(
|
|
63
|
+
name="org",
|
|
64
|
+
pattern="org-memory",
|
|
65
|
+
files=("recent.md", "decisions.md", "rules.md"),
|
|
66
|
+
dirs=("events", "debriefs"),
|
|
67
|
+
)
|
|
68
|
+
PROJECT = Tier(
|
|
69
|
+
name="project",
|
|
70
|
+
pattern=f"{PROJECTS_DIR}/{WILDCARD}/memory",
|
|
71
|
+
files=("project_facts.md", "decision_log.md", "open_threads.md", "known_debt.md"),
|
|
72
|
+
dirs=("archive",),
|
|
73
|
+
unsynced=(".cache",),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
#: The whole layout. The order is the order of the allowlist lines.
|
|
77
|
+
TIERS: Tuple[Tier, ...] = (ORG, PROJECT)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def gitignore_text() -> str:
|
|
81
|
+
"""The canonical sync allowlist for a home's ``.gitignore``, byte for byte."""
|
|
82
|
+
lines = [WILDCARD, f"!{WILDCARD}/", f"!{GITIGNORE_FILE}", f"!{MARKER_FILE}"]
|
|
83
|
+
lines.extend(f"!{tier.pattern}/**" for tier in TIERS)
|
|
84
|
+
lines.extend(f"{tier.pattern}/{sub}/" for tier in TIERS for sub in tier.unsynced)
|
|
85
|
+
lines.append(_NEVER_SYNCED_COMMENT)
|
|
86
|
+
lines.extend(f"{name}/" for name in NEVER_SYNCED_DIRS)
|
|
87
|
+
return "\n".join(lines) + "\n"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def org_memory_dir(home: Path) -> Path:
|
|
91
|
+
return home.joinpath(*ORG.parts)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def project_memory_dir(home: Path, project: str) -> Path:
|
|
95
|
+
validate_project_name(project)
|
|
96
|
+
return home.joinpath(*(project if part == WILDCARD else part for part in PROJECT.parts))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_project_name(project: str) -> None:
|
|
100
|
+
if not project or project.startswith(".") or "/" in project or "\\" in project:
|
|
101
|
+
raise ValueError("project name must be non-empty, contain no path separators and not start with '.'")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def allowed_memory_dirs(home: Path) -> List[Path]:
|
|
105
|
+
"""Existing tier directories under ``home``, in allowlist order; projects sorted by name."""
|
|
106
|
+
return [path for tier in TIERS for path in _expand(home, tier.parts) if path.exists()]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _expand(base: Path, parts: Sequence[str]) -> Iterator[Path]:
|
|
110
|
+
if not parts:
|
|
111
|
+
yield base
|
|
112
|
+
return
|
|
113
|
+
head, rest = parts[0], parts[1:]
|
|
114
|
+
if head != WILDCARD:
|
|
115
|
+
yield from _expand(base / head, rest)
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
children = sorted(base.iterdir())
|
|
119
|
+
except OSError:
|
|
120
|
+
return
|
|
121
|
+
for child in children:
|
|
122
|
+
yield from _expand(child, rest)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_allowed_memory_path(path: str) -> bool:
|
|
126
|
+
"""Whether a home-relative POSIX path is inside the sync allowlist.
|
|
127
|
+
|
|
128
|
+
A component named in ``NEVER_SYNCED_DIRS`` denies the path at any depth,
|
|
129
|
+
whatever the home's ``.gitignore`` says: the predicate, not the ignore
|
|
130
|
+
file, is what the sync engine trusts.
|
|
131
|
+
"""
|
|
132
|
+
if path in (GITIGNORE_FILE, MARKER_FILE):
|
|
133
|
+
return True
|
|
134
|
+
parts = path.split("/")
|
|
135
|
+
if any(part in NEVER_SYNCED_DIRS for part in parts):
|
|
136
|
+
return False
|
|
137
|
+
for tier in TIERS:
|
|
138
|
+
pattern = tier.parts
|
|
139
|
+
if len(parts) <= len(pattern):
|
|
140
|
+
continue
|
|
141
|
+
if all(want == WILDCARD or want == have for want, have in zip(pattern, parts)):
|
|
142
|
+
return parts[len(pattern)] not in tier.unsynced
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def scaffold_home(home: Path, project: Optional[str] = None) -> List[Path]:
|
|
147
|
+
"""Lay out ``home``: its directories, the canonical ``.gitignore`` and the org
|
|
148
|
+
tier, plus one project tier when ``project`` is given.
|
|
149
|
+
|
|
150
|
+
Only missing paths are created and nothing that exists is touched, so a
|
|
151
|
+
rerun on a complete home returns an empty list. The ``.gitignore`` slot
|
|
152
|
+
has a tier file's guarantee: whatever occupies it, a dangling link
|
|
153
|
+
included, is kept and never followed (:func:`write_if_absent`). Tier
|
|
154
|
+
*files* are not written here; their content ships with the scaffolding
|
|
155
|
+
verbs.
|
|
156
|
+
|
|
157
|
+
Returns the paths created, in creation order.
|
|
158
|
+
"""
|
|
159
|
+
created: List[Path] = []
|
|
160
|
+
|
|
161
|
+
def mkdir(path: Path) -> None:
|
|
162
|
+
if not path.is_dir():
|
|
163
|
+
path.mkdir(parents=True)
|
|
164
|
+
created.append(path)
|
|
165
|
+
|
|
166
|
+
mkdir(home)
|
|
167
|
+
gitignore = home / GITIGNORE_FILE
|
|
168
|
+
if write_if_absent(gitignore, gitignore_text().encode("utf-8")):
|
|
169
|
+
created.append(gitignore)
|
|
170
|
+
mkdir(home / PROJECTS_DIR)
|
|
171
|
+
_scaffold_tier(ORG, org_memory_dir(home), mkdir)
|
|
172
|
+
if project is not None:
|
|
173
|
+
_scaffold_tier(PROJECT, project_memory_dir(home, project), mkdir)
|
|
174
|
+
return created
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def write_if_absent(path: Path, data: bytes) -> bool:
|
|
178
|
+
"""Create ``path`` holding ``data`` when no directory entry is there; True when it was created.
|
|
179
|
+
|
|
180
|
+
Whatever occupies the slot is kept and never followed: a regular file, a
|
|
181
|
+
directory, or a symlink, dangling included. The existence probe is only
|
|
182
|
+
advisory; the open is exclusive, so an entry that appears between the two
|
|
183
|
+
is kept as well. Any other failure propagates as ``OSError``.
|
|
184
|
+
"""
|
|
185
|
+
if os.path.lexists(path):
|
|
186
|
+
return False
|
|
187
|
+
try:
|
|
188
|
+
with open(path, "xb") as handle:
|
|
189
|
+
handle.write(data)
|
|
190
|
+
except FileExistsError:
|
|
191
|
+
return False
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _scaffold_tier(tier: Tier, root: Path, mkdir: Callable[[Path], None]) -> None:
|
|
196
|
+
mkdir(root)
|
|
197
|
+
for sub in tier.dirs:
|
|
198
|
+
mkdir(root / sub)
|