folder-nature 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.
- folder_nature/__init__.py +57 -0
- folder_nature/cli.py +314 -0
- folder_nature/core.py +116 -0
- folder_nature/schema.py +247 -0
- folder_nature/search.py +131 -0
- folder_nature/templates/archive.yaml +13 -0
- folder_nature/templates/client-project.yaml +13 -0
- folder_nature/templates/deployment.yaml +14 -0
- folder_nature/templates/documentation.yaml +12 -0
- folder_nature/templates/workspace.yaml +11 -0
- folder_nature-0.1.0.dist-info/METADATA +205 -0
- folder_nature-0.1.0.dist-info/RECORD +16 -0
- folder_nature-0.1.0.dist-info/WHEEL +5 -0
- folder_nature-0.1.0.dist-info/entry_points.txt +2 -0
- folder_nature-0.1.0.dist-info/licenses/LICENSE +21 -0
- folder_nature-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""folder-nature — semantic identity for filesystem folders.
|
|
2
|
+
|
|
3
|
+
A directory becomes a conversation partner: it has identity, purpose, rules,
|
|
4
|
+
and history, all captured in a hidden ``.folder-nature`` YAML file.
|
|
5
|
+
|
|
6
|
+
Public API:
|
|
7
|
+
|
|
8
|
+
from folder_nature import (
|
|
9
|
+
FolderNature,
|
|
10
|
+
SchemaError,
|
|
11
|
+
find_director,
|
|
12
|
+
get_folder_nature,
|
|
13
|
+
write_folder_nature,
|
|
14
|
+
search,
|
|
15
|
+
validate,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
CLI entry point: ``folder-nature`` (see ``folder_nature.cli``).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
__schema_version__ = "1.0"
|
|
25
|
+
|
|
26
|
+
from .schema import (
|
|
27
|
+
FolderNature,
|
|
28
|
+
SchemaError,
|
|
29
|
+
SCHEMA_VERSION,
|
|
30
|
+
BEING_TYPES,
|
|
31
|
+
validate as validate_data,
|
|
32
|
+
)
|
|
33
|
+
from .core import (
|
|
34
|
+
find_director,
|
|
35
|
+
get_folder_nature,
|
|
36
|
+
read_folder_nature,
|
|
37
|
+
write_folder_nature,
|
|
38
|
+
NATURE_FILENAME,
|
|
39
|
+
)
|
|
40
|
+
from .search import search, list_all
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"__version__",
|
|
44
|
+
"__schema_version__",
|
|
45
|
+
"FolderNature",
|
|
46
|
+
"SchemaError",
|
|
47
|
+
"SCHEMA_VERSION",
|
|
48
|
+
"BEING_TYPES",
|
|
49
|
+
"NATURE_FILENAME",
|
|
50
|
+
"validate_data",
|
|
51
|
+
"find_director",
|
|
52
|
+
"get_folder_nature",
|
|
53
|
+
"read_folder_nature",
|
|
54
|
+
"write_folder_nature",
|
|
55
|
+
"search",
|
|
56
|
+
"list_all",
|
|
57
|
+
]
|
folder_nature/cli.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Command-line interface — the ``folder-nature`` binary.
|
|
2
|
+
|
|
3
|
+
Subcommands (MVP):
|
|
4
|
+
init Create a new .folder-nature in the target directory
|
|
5
|
+
show Print the closest .folder-nature (walks up from target)
|
|
6
|
+
query Find the director ancestor of target
|
|
7
|
+
search Find folders matching --tag/--being/--name
|
|
8
|
+
validate Check schema-validity of all .folder-nature files in tree
|
|
9
|
+
list Tree-view of every .folder-nature
|
|
10
|
+
version Print tool + schema versions
|
|
11
|
+
|
|
12
|
+
Phase 2 (post-MVP) subcommands: ai-suggest, watch, migrate, export, import.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import importlib.resources as pkg_resources
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from . import __schema_version__, __version__
|
|
26
|
+
from .core import (
|
|
27
|
+
NATURE_FILENAME,
|
|
28
|
+
find_director,
|
|
29
|
+
get_folder_nature,
|
|
30
|
+
read_folder_nature,
|
|
31
|
+
write_folder_nature,
|
|
32
|
+
)
|
|
33
|
+
from .schema import (
|
|
34
|
+
BEING_TYPES,
|
|
35
|
+
FolderNature,
|
|
36
|
+
Identity,
|
|
37
|
+
Memory,
|
|
38
|
+
SchemaError,
|
|
39
|
+
from_dict,
|
|
40
|
+
)
|
|
41
|
+
from .search import iter_nature_file_paths, list_all, search
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Default templates ship in the package; user-selectable via ``--template``.
|
|
45
|
+
DEFAULT_TEMPLATES = (
|
|
46
|
+
"workspace",
|
|
47
|
+
"client-project",
|
|
48
|
+
"archive",
|
|
49
|
+
"deployment",
|
|
50
|
+
"documentation",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── Template loading ────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_template(name: str) -> dict:
|
|
58
|
+
"""Load a template from the packaged templates dir.
|
|
59
|
+
|
|
60
|
+
Returns the parsed YAML dict. Raises :class:`FileNotFoundError` if name
|
|
61
|
+
isn't a known template.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
# importlib.resources for Python 3.9+; falls back to traversable
|
|
65
|
+
files = pkg_resources.files("folder_nature.templates")
|
|
66
|
+
candidate = files / f"{name}.yaml"
|
|
67
|
+
if not candidate.is_file():
|
|
68
|
+
raise FileNotFoundError(
|
|
69
|
+
f"unknown template {name!r}. "
|
|
70
|
+
f"Available: {', '.join(sorted(DEFAULT_TEMPLATES))}"
|
|
71
|
+
)
|
|
72
|
+
return yaml.safe_load(candidate.read_text(encoding="utf-8"))
|
|
73
|
+
except FileNotFoundError:
|
|
74
|
+
raise
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── Subcommand handlers ────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
81
|
+
"""folder-nature init [PATH] [--template NAME]"""
|
|
82
|
+
target = Path(args.path).resolve()
|
|
83
|
+
if not target.exists():
|
|
84
|
+
print(f"folder-nature: error: directory does not exist: {target}", file=sys.stderr)
|
|
85
|
+
return 2
|
|
86
|
+
if not target.is_dir():
|
|
87
|
+
print(f"folder-nature: error: not a directory: {target}", file=sys.stderr)
|
|
88
|
+
return 2
|
|
89
|
+
|
|
90
|
+
nature_file = target / NATURE_FILENAME
|
|
91
|
+
if nature_file.exists() and not args.force:
|
|
92
|
+
print(
|
|
93
|
+
f"folder-nature: error: {NATURE_FILENAME} already exists at {target}. "
|
|
94
|
+
"Use --force to overwrite.",
|
|
95
|
+
file=sys.stderr,
|
|
96
|
+
)
|
|
97
|
+
return 1
|
|
98
|
+
|
|
99
|
+
if args.template:
|
|
100
|
+
try:
|
|
101
|
+
data = _load_template(args.template)
|
|
102
|
+
except FileNotFoundError as e:
|
|
103
|
+
print(f"folder-nature: error: {e}", file=sys.stderr)
|
|
104
|
+
return 2
|
|
105
|
+
# If --name is provided, override the template's identity.name with
|
|
106
|
+
# the actual directory name (templates use placeholder).
|
|
107
|
+
if "identity" in data and isinstance(data["identity"], dict):
|
|
108
|
+
data["identity"].setdefault("name", target.name)
|
|
109
|
+
if args.name:
|
|
110
|
+
data["identity"]["name"] = args.name
|
|
111
|
+
try:
|
|
112
|
+
nature = from_dict(data)
|
|
113
|
+
except SchemaError as e:
|
|
114
|
+
print(f"folder-nature: error: template {args.template!r} invalid: {e}",
|
|
115
|
+
file=sys.stderr)
|
|
116
|
+
return 1
|
|
117
|
+
else:
|
|
118
|
+
# Minimal nature without template — user provides name/being/purpose
|
|
119
|
+
if not (args.name and args.being and args.purpose):
|
|
120
|
+
print(
|
|
121
|
+
"folder-nature: error: without --template, you must provide "
|
|
122
|
+
"--name, --being, and --purpose",
|
|
123
|
+
file=sys.stderr,
|
|
124
|
+
)
|
|
125
|
+
print(
|
|
126
|
+
f" Or pick a template with --template (one of: "
|
|
127
|
+
f"{', '.join(sorted(DEFAULT_TEMPLATES))})",
|
|
128
|
+
file=sys.stderr,
|
|
129
|
+
)
|
|
130
|
+
return 2
|
|
131
|
+
nature = FolderNature(
|
|
132
|
+
identity=Identity(
|
|
133
|
+
name=args.name,
|
|
134
|
+
being=args.being,
|
|
135
|
+
purpose=args.purpose,
|
|
136
|
+
),
|
|
137
|
+
director=args.director,
|
|
138
|
+
tags=args.tag or [],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
written_path = write_folder_nature(target, nature)
|
|
143
|
+
except SchemaError as e:
|
|
144
|
+
print(f"folder-nature: error: validation failed: {e}", file=sys.stderr)
|
|
145
|
+
return 1
|
|
146
|
+
print(f"created {written_path}")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
151
|
+
"""folder-nature show [PATH] — print closest .folder-nature."""
|
|
152
|
+
target = Path(args.path).resolve()
|
|
153
|
+
nature = get_folder_nature(target)
|
|
154
|
+
if nature is None:
|
|
155
|
+
print(
|
|
156
|
+
f"folder-nature: no {NATURE_FILENAME} found in {target} or any ancestor",
|
|
157
|
+
file=sys.stderr,
|
|
158
|
+
)
|
|
159
|
+
return 1
|
|
160
|
+
print(yaml.safe_dump(
|
|
161
|
+
nature.to_dict(),
|
|
162
|
+
sort_keys=False,
|
|
163
|
+
allow_unicode=True,
|
|
164
|
+
default_flow_style=False,
|
|
165
|
+
).rstrip())
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def cmd_query(args: argparse.Namespace) -> int:
|
|
170
|
+
"""folder-nature query [PATH] — find director ancestor."""
|
|
171
|
+
target = Path(args.path).resolve()
|
|
172
|
+
director_path, nature = find_director(target)
|
|
173
|
+
if director_path is None or nature is None:
|
|
174
|
+
print(
|
|
175
|
+
f"folder-nature: no director found walking up from {target}",
|
|
176
|
+
file=sys.stderr,
|
|
177
|
+
)
|
|
178
|
+
return 1
|
|
179
|
+
print(f"{director_path}")
|
|
180
|
+
if args.verbose:
|
|
181
|
+
print()
|
|
182
|
+
print(yaml.safe_dump(
|
|
183
|
+
nature.to_dict(),
|
|
184
|
+
sort_keys=False,
|
|
185
|
+
allow_unicode=True,
|
|
186
|
+
default_flow_style=False,
|
|
187
|
+
).rstrip())
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
192
|
+
"""folder-nature search [--tag X] [--being Y] [--name Z] [ROOT]"""
|
|
193
|
+
root = Path(args.root).resolve()
|
|
194
|
+
results = search(root, tag=args.tag, being=args.being, name=args.name)
|
|
195
|
+
if not results:
|
|
196
|
+
print(f"folder-nature: no matches under {root}", file=sys.stderr)
|
|
197
|
+
return 1
|
|
198
|
+
for nature in results:
|
|
199
|
+
if nature.identity is None:
|
|
200
|
+
continue
|
|
201
|
+
tags = ",".join(nature.tags) if nature.tags else "-"
|
|
202
|
+
print(f"{nature.identity.being:14s} {nature.identity.name:30s} tags=[{tags}]")
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
207
|
+
"""folder-nature validate [ROOT] — schema-check all .folder-nature files."""
|
|
208
|
+
root = Path(args.root).resolve()
|
|
209
|
+
total = 0
|
|
210
|
+
errors = 0
|
|
211
|
+
for nf in iter_nature_file_paths(root):
|
|
212
|
+
total += 1
|
|
213
|
+
try:
|
|
214
|
+
read_folder_nature(nf)
|
|
215
|
+
except SchemaError as e:
|
|
216
|
+
errors += 1
|
|
217
|
+
print(f"INVALID {nf}: {e}")
|
|
218
|
+
except yaml.YAMLError as e:
|
|
219
|
+
errors += 1
|
|
220
|
+
print(f"BAD-YAML {nf}: {e}")
|
|
221
|
+
except OSError as e:
|
|
222
|
+
errors += 1
|
|
223
|
+
print(f"IO-ERROR {nf}: {e}")
|
|
224
|
+
print(f"\nchecked {total} file(s), {errors} error(s)")
|
|
225
|
+
return 0 if errors == 0 else 1
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
229
|
+
"""folder-nature list [ROOT] — tree-view of all .folder-nature files."""
|
|
230
|
+
root = Path(args.root).resolve()
|
|
231
|
+
natures = list_all(root)
|
|
232
|
+
if not natures:
|
|
233
|
+
print(f"folder-nature: no .folder-nature files under {root}", file=sys.stderr)
|
|
234
|
+
return 1
|
|
235
|
+
for nature in natures:
|
|
236
|
+
if nature.identity is None:
|
|
237
|
+
continue
|
|
238
|
+
tags = ",".join(nature.tags) if nature.tags else "-"
|
|
239
|
+
marker = "*" if nature.director else " "
|
|
240
|
+
print(
|
|
241
|
+
f"{marker} {nature.identity.being:14s} {nature.identity.name:30s} "
|
|
242
|
+
f"tags=[{tags}]"
|
|
243
|
+
)
|
|
244
|
+
print(f"\n{len(natures)} folder-nature file(s) under {root}")
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def cmd_version(args: argparse.Namespace) -> int:
|
|
249
|
+
"""folder-nature version"""
|
|
250
|
+
print(f"folder-nature {__version__} (schema v{__schema_version__})")
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ── argparse plumbing ──────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
258
|
+
parser = argparse.ArgumentParser(
|
|
259
|
+
prog="folder-nature",
|
|
260
|
+
description="Semantic identity for filesystem folders.",
|
|
261
|
+
)
|
|
262
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
263
|
+
|
|
264
|
+
p_init = sub.add_parser("init", help="Create a new .folder-nature in directory")
|
|
265
|
+
p_init.add_argument("path", nargs="?", default=".", help="Target directory (default: cwd)")
|
|
266
|
+
p_init.add_argument("--template", choices=sorted(DEFAULT_TEMPLATES),
|
|
267
|
+
help="Use a packaged template")
|
|
268
|
+
p_init.add_argument("--name", help="identity.name (overrides template default)")
|
|
269
|
+
p_init.add_argument("--being", choices=sorted(BEING_TYPES),
|
|
270
|
+
help="identity.being (required without --template)")
|
|
271
|
+
p_init.add_argument("--purpose", help="identity.purpose (required without --template)")
|
|
272
|
+
p_init.add_argument("--tag", action="append", help="Add a tag (repeatable)")
|
|
273
|
+
p_init.add_argument("--director", action="store_true", help="Mark as director")
|
|
274
|
+
p_init.add_argument("--force", action="store_true", help="Overwrite existing")
|
|
275
|
+
p_init.set_defaults(func=cmd_init)
|
|
276
|
+
|
|
277
|
+
p_show = sub.add_parser("show", help="Print closest .folder-nature")
|
|
278
|
+
p_show.add_argument("path", nargs="?", default=".")
|
|
279
|
+
p_show.set_defaults(func=cmd_show)
|
|
280
|
+
|
|
281
|
+
p_query = sub.add_parser("query", help="Find director ancestor")
|
|
282
|
+
p_query.add_argument("path", nargs="?", default=".")
|
|
283
|
+
p_query.add_argument("--verbose", "-v", action="store_true",
|
|
284
|
+
help="Also print the director's .folder-nature content")
|
|
285
|
+
p_query.set_defaults(func=cmd_query)
|
|
286
|
+
|
|
287
|
+
p_search = sub.add_parser("search", help="Find folders matching criteria")
|
|
288
|
+
p_search.add_argument("root", nargs="?", default=".")
|
|
289
|
+
p_search.add_argument("--tag", help="Substring match against tags")
|
|
290
|
+
p_search.add_argument("--being", choices=sorted(BEING_TYPES))
|
|
291
|
+
p_search.add_argument("--name", help="Substring match against name")
|
|
292
|
+
p_search.set_defaults(func=cmd_search)
|
|
293
|
+
|
|
294
|
+
p_validate = sub.add_parser("validate", help="Schema-check tree")
|
|
295
|
+
p_validate.add_argument("root", nargs="?", default=".")
|
|
296
|
+
p_validate.set_defaults(func=cmd_validate)
|
|
297
|
+
|
|
298
|
+
p_list = sub.add_parser("list", help="Tree-view of all .folder-nature files")
|
|
299
|
+
p_list.add_argument("root", nargs="?", default=".")
|
|
300
|
+
p_list.set_defaults(func=cmd_list)
|
|
301
|
+
|
|
302
|
+
sub.add_parser("version", help="Print version").set_defaults(func=cmd_version)
|
|
303
|
+
|
|
304
|
+
return parser
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def main(argv: Optional[list] = None) -> int:
|
|
308
|
+
parser = build_parser()
|
|
309
|
+
args = parser.parse_args(argv)
|
|
310
|
+
return args.func(args)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
if __name__ == "__main__":
|
|
314
|
+
sys.exit(main())
|
folder_nature/core.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Filesystem operations on ``.folder-nature`` files.
|
|
2
|
+
|
|
3
|
+
Read, write, walk-upward-to-director. All paths are :class:`pathlib.Path`.
|
|
4
|
+
YAML I/O via PyYAML.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterator, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from .schema import FolderNature, from_dict, SchemaError, SCHEMA_VERSION
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
NATURE_FILENAME = ".folder-nature"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _walk_upward(start: Path) -> Iterator[Path]:
|
|
21
|
+
"""Yield ancestor directories of ``start`` (inclusive), stopping at root.
|
|
22
|
+
|
|
23
|
+
Ensures the walk always terminates — yields each ancestor exactly once,
|
|
24
|
+
stops when ``current.parent == current`` (filesystem root).
|
|
25
|
+
"""
|
|
26
|
+
current = start.resolve()
|
|
27
|
+
if current.is_file():
|
|
28
|
+
current = current.parent
|
|
29
|
+
seen = set()
|
|
30
|
+
while True:
|
|
31
|
+
if current in seen:
|
|
32
|
+
return
|
|
33
|
+
seen.add(current)
|
|
34
|
+
yield current
|
|
35
|
+
parent = current.parent
|
|
36
|
+
if parent == current:
|
|
37
|
+
return
|
|
38
|
+
current = parent
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_folder_nature(path: Path) -> FolderNature:
|
|
42
|
+
"""Read + validate a single ``.folder-nature`` file at ``path``.
|
|
43
|
+
|
|
44
|
+
Raises :class:`FileNotFoundError` if path doesn't exist.
|
|
45
|
+
Raises :class:`SchemaError` if file content fails validation.
|
|
46
|
+
Raises :class:`yaml.YAMLError` if file isn't valid YAML.
|
|
47
|
+
"""
|
|
48
|
+
path = Path(path)
|
|
49
|
+
if not path.exists():
|
|
50
|
+
raise FileNotFoundError(f"no such file: {path}")
|
|
51
|
+
with path.open("r", encoding="utf-8") as f:
|
|
52
|
+
data = yaml.safe_load(f)
|
|
53
|
+
if data is None:
|
|
54
|
+
raise SchemaError(f"{path}: file is empty or contains only YAML null")
|
|
55
|
+
return from_dict(data)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write_folder_nature(directory: Path, nature: FolderNature) -> Path:
|
|
59
|
+
"""Write a FolderNature to ``directory/.folder-nature``.
|
|
60
|
+
|
|
61
|
+
Validates the nature before writing. Returns the path written.
|
|
62
|
+
Overwrites existing file (caller's responsibility to confirm).
|
|
63
|
+
"""
|
|
64
|
+
directory = Path(directory)
|
|
65
|
+
if not directory.exists():
|
|
66
|
+
raise FileNotFoundError(f"directory does not exist: {directory}")
|
|
67
|
+
if not directory.is_dir():
|
|
68
|
+
raise NotADirectoryError(f"not a directory: {directory}")
|
|
69
|
+
|
|
70
|
+
nature.validate()
|
|
71
|
+
target = directory / NATURE_FILENAME
|
|
72
|
+
with target.open("w", encoding="utf-8") as f:
|
|
73
|
+
yaml.safe_dump(
|
|
74
|
+
nature.to_dict(),
|
|
75
|
+
f,
|
|
76
|
+
sort_keys=False,
|
|
77
|
+
allow_unicode=True,
|
|
78
|
+
default_flow_style=False,
|
|
79
|
+
)
|
|
80
|
+
return target
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_folder_nature(path: Path) -> Optional[FolderNature]:
|
|
84
|
+
"""Return the closest ``.folder-nature`` walking upward from ``path``.
|
|
85
|
+
|
|
86
|
+
Returns ``None`` if no ``.folder-nature`` exists in any ancestor.
|
|
87
|
+
Raises :class:`SchemaError` if the closest file is invalid (caller can
|
|
88
|
+
catch + try the next ancestor if desired).
|
|
89
|
+
"""
|
|
90
|
+
for ancestor in _walk_upward(Path(path)):
|
|
91
|
+
nature_file = ancestor / NATURE_FILENAME
|
|
92
|
+
if nature_file.exists():
|
|
93
|
+
return read_folder_nature(nature_file)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def find_director(path: Path) -> Tuple[Optional[Path], Optional[FolderNature]]:
|
|
98
|
+
"""Walk upward until finding a folder-nature with ``director: true``.
|
|
99
|
+
|
|
100
|
+
Returns ``(director_path, nature)`` or ``(None, None)`` if no director
|
|
101
|
+
is found in any ancestor.
|
|
102
|
+
|
|
103
|
+
Skips ancestors with invalid folder-nature files (logs nothing — caller
|
|
104
|
+
can run ``validate`` to find broken files explicitly).
|
|
105
|
+
"""
|
|
106
|
+
for ancestor in _walk_upward(Path(path)):
|
|
107
|
+
nature_file = ancestor / NATURE_FILENAME
|
|
108
|
+
if not nature_file.exists():
|
|
109
|
+
continue
|
|
110
|
+
try:
|
|
111
|
+
nature = read_folder_nature(nature_file)
|
|
112
|
+
except (SchemaError, yaml.YAMLError):
|
|
113
|
+
continue
|
|
114
|
+
if nature.director:
|
|
115
|
+
return ancestor, nature
|
|
116
|
+
return None, None
|
folder_nature/schema.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Schema v1.0 — defines the shape of a ``.folder-nature`` file.
|
|
2
|
+
|
|
3
|
+
A folder-nature is structured YAML with required + optional fields and a
|
|
4
|
+
controlled vocabulary for the ``identity.being`` field. Schema is versioned
|
|
5
|
+
so future bumps can migrate cleanly via :mod:`folder_nature.migrate`.
|
|
6
|
+
|
|
7
|
+
This module is pure — no filesystem I/O, no YAML parsing. Just dataclasses
|
|
8
|
+
and validation. Use :mod:`folder_nature.core` for read/write.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field, asdict
|
|
14
|
+
from datetime import date
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
SCHEMA_VERSION = "1.0"
|
|
19
|
+
|
|
20
|
+
# Controlled vocabulary for `identity.being`. Extensible — unknown values
|
|
21
|
+
# log a warning during validation but don't reject the file.
|
|
22
|
+
BEING_TYPES = frozenset({
|
|
23
|
+
"director", # root/boss folder, children inherit
|
|
24
|
+
"collector", # archive/historical storage
|
|
25
|
+
"workspace", # active working space
|
|
26
|
+
"assets", # non-code resources (images, fonts, media)
|
|
27
|
+
"configs", # system/app configuration
|
|
28
|
+
"documentation", # docs, references, guides
|
|
29
|
+
"ideas", # unstructured exploration
|
|
30
|
+
"external", # third-party content (vendored, downloaded)
|
|
31
|
+
"legal", # contracts, agreements, compliance
|
|
32
|
+
"team-shared", # multi-person collaboration
|
|
33
|
+
"private", # sensitive/restricted
|
|
34
|
+
"system", # system-managed (don't manually edit)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
# Field-level constraints
|
|
38
|
+
MAX_NAME_LEN = 100
|
|
39
|
+
MAX_PURPOSE_LEN = 500
|
|
40
|
+
MAX_TAG_LEN = 30
|
|
41
|
+
MAX_RULE_LEN = 500
|
|
42
|
+
MAX_TAGS_COUNT = 50
|
|
43
|
+
MAX_RULES_COUNT = 100
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SchemaError(ValueError):
|
|
47
|
+
"""Raised on validation failure. Message names the offending field."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Identity:
|
|
52
|
+
"""The mandatory identity block of a folder-nature."""
|
|
53
|
+
name: str
|
|
54
|
+
being: str
|
|
55
|
+
purpose: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Memory:
|
|
60
|
+
"""Optional historical context. All fields optional within the block."""
|
|
61
|
+
created: Optional[str] = None # ISO date YYYY-MM-DD
|
|
62
|
+
last_significant_change: Optional[str] = None # ISO date
|
|
63
|
+
notable_events: List[str] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class FolderNature:
|
|
68
|
+
"""A parsed, validated folder-nature record.
|
|
69
|
+
|
|
70
|
+
Construct via :func:`folder_nature.schema.from_dict` (does validation) or
|
|
71
|
+
by manual instantiation + explicit :meth:`validate` call.
|
|
72
|
+
"""
|
|
73
|
+
schema_version: str = SCHEMA_VERSION
|
|
74
|
+
identity: Optional[Identity] = None
|
|
75
|
+
director: bool = False
|
|
76
|
+
tags: List[str] = field(default_factory=list)
|
|
77
|
+
rules: List[str] = field(default_factory=list)
|
|
78
|
+
memory: Optional[Memory] = None
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
81
|
+
"""Convert to a YAML-friendly dict. Drops None values for cleanliness."""
|
|
82
|
+
out: Dict[str, Any] = {"schema_version": self.schema_version}
|
|
83
|
+
if self.identity is not None:
|
|
84
|
+
out["identity"] = {
|
|
85
|
+
"name": self.identity.name,
|
|
86
|
+
"being": self.identity.being,
|
|
87
|
+
"purpose": self.identity.purpose,
|
|
88
|
+
}
|
|
89
|
+
out["director"] = self.director
|
|
90
|
+
if self.tags:
|
|
91
|
+
out["tags"] = list(self.tags)
|
|
92
|
+
if self.rules:
|
|
93
|
+
out["rules"] = list(self.rules)
|
|
94
|
+
if self.memory is not None:
|
|
95
|
+
mem: Dict[str, Any] = {}
|
|
96
|
+
if self.memory.created:
|
|
97
|
+
mem["created"] = self.memory.created
|
|
98
|
+
if self.memory.last_significant_change:
|
|
99
|
+
mem["last_significant_change"] = self.memory.last_significant_change
|
|
100
|
+
if self.memory.notable_events:
|
|
101
|
+
mem["notable_events"] = list(self.memory.notable_events)
|
|
102
|
+
if mem:
|
|
103
|
+
out["memory"] = mem
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
def validate(self) -> None:
|
|
107
|
+
"""Run all schema checks. Raises SchemaError on first violation."""
|
|
108
|
+
validate(self.to_dict())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def from_dict(data: Dict[str, Any]) -> FolderNature:
|
|
112
|
+
"""Construct + validate a FolderNature from a parsed YAML dict.
|
|
113
|
+
|
|
114
|
+
Raises :class:`SchemaError` if the dict doesn't conform.
|
|
115
|
+
"""
|
|
116
|
+
validate(data)
|
|
117
|
+
|
|
118
|
+
identity_raw = data["identity"]
|
|
119
|
+
identity = Identity(
|
|
120
|
+
name=identity_raw["name"],
|
|
121
|
+
being=identity_raw["being"],
|
|
122
|
+
purpose=identity_raw["purpose"],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
memory: Optional[Memory] = None
|
|
126
|
+
if "memory" in data:
|
|
127
|
+
mem_raw = data["memory"]
|
|
128
|
+
memory = Memory(
|
|
129
|
+
created=mem_raw.get("created"),
|
|
130
|
+
last_significant_change=mem_raw.get("last_significant_change"),
|
|
131
|
+
notable_events=list(mem_raw.get("notable_events", [])),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return FolderNature(
|
|
135
|
+
schema_version=data.get("schema_version", SCHEMA_VERSION),
|
|
136
|
+
identity=identity,
|
|
137
|
+
director=bool(data.get("director", False)),
|
|
138
|
+
tags=list(data.get("tags", [])),
|
|
139
|
+
rules=list(data.get("rules", [])),
|
|
140
|
+
memory=memory,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def validate(data: Dict[str, Any]) -> None:
|
|
145
|
+
"""Validate a parsed YAML dict against schema v1.0.
|
|
146
|
+
|
|
147
|
+
Raises :class:`SchemaError` with a descriptive message on first violation.
|
|
148
|
+
Returns None on success.
|
|
149
|
+
"""
|
|
150
|
+
if not isinstance(data, dict):
|
|
151
|
+
raise SchemaError("root must be a mapping/dict")
|
|
152
|
+
|
|
153
|
+
# schema_version
|
|
154
|
+
version = data.get("schema_version")
|
|
155
|
+
if version is None:
|
|
156
|
+
raise SchemaError("schema_version is required")
|
|
157
|
+
if not isinstance(version, str):
|
|
158
|
+
raise SchemaError(f"schema_version must be a string, got {type(version).__name__}")
|
|
159
|
+
if version != SCHEMA_VERSION:
|
|
160
|
+
raise SchemaError(
|
|
161
|
+
f"unsupported schema_version {version!r}; "
|
|
162
|
+
f"this build supports {SCHEMA_VERSION!r} only. "
|
|
163
|
+
"Use `folder-nature migrate` for older versions."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# identity (required)
|
|
167
|
+
if "identity" not in data:
|
|
168
|
+
raise SchemaError("identity block is required")
|
|
169
|
+
identity = data["identity"]
|
|
170
|
+
if not isinstance(identity, dict):
|
|
171
|
+
raise SchemaError("identity must be a mapping/dict")
|
|
172
|
+
|
|
173
|
+
for fname in ("name", "being", "purpose"):
|
|
174
|
+
if fname not in identity:
|
|
175
|
+
raise SchemaError(f"identity.{fname} is required")
|
|
176
|
+
val = identity[fname]
|
|
177
|
+
if not isinstance(val, str):
|
|
178
|
+
raise SchemaError(f"identity.{fname} must be a string")
|
|
179
|
+
if not val.strip():
|
|
180
|
+
raise SchemaError(f"identity.{fname} must be non-empty")
|
|
181
|
+
|
|
182
|
+
if len(identity["name"]) > MAX_NAME_LEN:
|
|
183
|
+
raise SchemaError(f"identity.name exceeds {MAX_NAME_LEN} chars")
|
|
184
|
+
if len(identity["purpose"]) > MAX_PURPOSE_LEN:
|
|
185
|
+
raise SchemaError(f"identity.purpose exceeds {MAX_PURPOSE_LEN} chars")
|
|
186
|
+
|
|
187
|
+
being = identity["being"]
|
|
188
|
+
if being not in BEING_TYPES:
|
|
189
|
+
# Unknown but not rejected — extensibility hatch. Soft warning only.
|
|
190
|
+
# (Validation passes; a callable warning hook could be added later.)
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
# director (optional, bool)
|
|
194
|
+
if "director" in data and not isinstance(data["director"], bool):
|
|
195
|
+
raise SchemaError("director must be a boolean")
|
|
196
|
+
|
|
197
|
+
# tags (optional, list of short strings)
|
|
198
|
+
if "tags" in data:
|
|
199
|
+
tags = data["tags"]
|
|
200
|
+
if not isinstance(tags, list):
|
|
201
|
+
raise SchemaError("tags must be a list")
|
|
202
|
+
if len(tags) > MAX_TAGS_COUNT:
|
|
203
|
+
raise SchemaError(f"tags count exceeds {MAX_TAGS_COUNT}")
|
|
204
|
+
for i, tag in enumerate(tags):
|
|
205
|
+
if not isinstance(tag, str):
|
|
206
|
+
raise SchemaError(f"tags[{i}] must be a string")
|
|
207
|
+
if len(tag) > MAX_TAG_LEN:
|
|
208
|
+
raise SchemaError(f"tags[{i}] exceeds {MAX_TAG_LEN} chars")
|
|
209
|
+
if not tag.strip():
|
|
210
|
+
raise SchemaError(f"tags[{i}] must be non-empty")
|
|
211
|
+
|
|
212
|
+
# rules (optional, list of strings)
|
|
213
|
+
if "rules" in data:
|
|
214
|
+
rules = data["rules"]
|
|
215
|
+
if not isinstance(rules, list):
|
|
216
|
+
raise SchemaError("rules must be a list")
|
|
217
|
+
if len(rules) > MAX_RULES_COUNT:
|
|
218
|
+
raise SchemaError(f"rules count exceeds {MAX_RULES_COUNT}")
|
|
219
|
+
for i, rule in enumerate(rules):
|
|
220
|
+
if not isinstance(rule, str):
|
|
221
|
+
raise SchemaError(f"rules[{i}] must be a string")
|
|
222
|
+
if len(rule) > MAX_RULE_LEN:
|
|
223
|
+
raise SchemaError(f"rules[{i}] exceeds {MAX_RULE_LEN} chars")
|
|
224
|
+
|
|
225
|
+
# memory (optional, dict)
|
|
226
|
+
if "memory" in data:
|
|
227
|
+
mem = data["memory"]
|
|
228
|
+
if not isinstance(mem, dict):
|
|
229
|
+
raise SchemaError("memory must be a mapping/dict")
|
|
230
|
+
for date_field in ("created", "last_significant_change"):
|
|
231
|
+
if date_field in mem and mem[date_field] is not None:
|
|
232
|
+
if not isinstance(mem[date_field], str):
|
|
233
|
+
raise SchemaError(f"memory.{date_field} must be a string (ISO date)")
|
|
234
|
+
# Light date format check — YYYY-MM-DD
|
|
235
|
+
val = mem[date_field]
|
|
236
|
+
if len(val) >= 10 and val[4] == "-" and val[7] == "-":
|
|
237
|
+
try:
|
|
238
|
+
date.fromisoformat(val[:10])
|
|
239
|
+
except ValueError as e:
|
|
240
|
+
raise SchemaError(f"memory.{date_field} not a valid ISO date: {e}")
|
|
241
|
+
if "notable_events" in mem:
|
|
242
|
+
events = mem["notable_events"]
|
|
243
|
+
if not isinstance(events, list):
|
|
244
|
+
raise SchemaError("memory.notable_events must be a list")
|
|
245
|
+
for i, ev in enumerate(events):
|
|
246
|
+
if not isinstance(ev, str):
|
|
247
|
+
raise SchemaError(f"memory.notable_events[{i}] must be a string")
|
folder_nature/search.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Search + list operations over a directory tree.
|
|
2
|
+
|
|
3
|
+
These functions walk the filesystem looking for ``.folder-nature`` files and
|
|
4
|
+
filter/sort by tag, being, or schema-validity. Walking respects standard
|
|
5
|
+
exclude patterns (hidden dirs starting with ``.`` other than the user's
|
|
6
|
+
content roots, virtual environments, build artifacts).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterator, List, Optional
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from .core import NATURE_FILENAME, read_folder_nature
|
|
17
|
+
from .schema import FolderNature, SchemaError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Directory names skipped during search/list — performance + sanity.
|
|
21
|
+
# Hidden directories (starting with ``.``) are also skipped EXCEPT this list
|
|
22
|
+
# (which lets the tool find folders that intentionally start with a dot).
|
|
23
|
+
_SKIP_DIRS = frozenset({
|
|
24
|
+
"__pycache__",
|
|
25
|
+
"node_modules",
|
|
26
|
+
".git",
|
|
27
|
+
".venv",
|
|
28
|
+
"venv",
|
|
29
|
+
"env",
|
|
30
|
+
".tox",
|
|
31
|
+
".pytest_cache",
|
|
32
|
+
"build",
|
|
33
|
+
"dist",
|
|
34
|
+
".eggs",
|
|
35
|
+
".mypy_cache",
|
|
36
|
+
".ruff_cache",
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _iter_nature_files(root: Path) -> Iterator[Path]:
|
|
41
|
+
"""Yield every ``.folder-nature`` file under ``root``, depth-first.
|
|
42
|
+
|
|
43
|
+
Skips directories in :data:`_SKIP_DIRS` and hidden directories (starting
|
|
44
|
+
with ``.`` and length > 1) for performance + cleanliness. The root itself
|
|
45
|
+
is always searched even if it starts with a dot.
|
|
46
|
+
"""
|
|
47
|
+
root = Path(root).resolve()
|
|
48
|
+
if not root.exists() or not root.is_dir():
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
stack: List[Path] = [root]
|
|
52
|
+
while stack:
|
|
53
|
+
current = stack.pop()
|
|
54
|
+
try:
|
|
55
|
+
children = list(current.iterdir())
|
|
56
|
+
except (OSError, PermissionError):
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
# First, check this directory itself for a folder-nature file
|
|
60
|
+
nature_file = current / NATURE_FILENAME
|
|
61
|
+
if nature_file.exists() and nature_file.is_file():
|
|
62
|
+
yield nature_file
|
|
63
|
+
|
|
64
|
+
# Then descend into subdirectories
|
|
65
|
+
for child in children:
|
|
66
|
+
if not child.is_dir():
|
|
67
|
+
continue
|
|
68
|
+
name = child.name
|
|
69
|
+
if name in _SKIP_DIRS:
|
|
70
|
+
continue
|
|
71
|
+
# Skip hidden dirs unless this is the search root
|
|
72
|
+
if name.startswith(".") and len(name) > 1:
|
|
73
|
+
continue
|
|
74
|
+
stack.append(child)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def list_all(root: Path) -> List[FolderNature]:
|
|
78
|
+
"""Return every valid FolderNature found under ``root`` (depth-first).
|
|
79
|
+
|
|
80
|
+
Invalid YAML or schema violations are silently skipped — use
|
|
81
|
+
:func:`folder_nature.cli.validate_tree` to report errors explicitly.
|
|
82
|
+
"""
|
|
83
|
+
out: List[FolderNature] = []
|
|
84
|
+
for nf in _iter_nature_files(root):
|
|
85
|
+
try:
|
|
86
|
+
out.append(read_folder_nature(nf))
|
|
87
|
+
except (SchemaError, yaml.YAMLError, OSError):
|
|
88
|
+
continue
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def search(
|
|
93
|
+
root: Path,
|
|
94
|
+
*,
|
|
95
|
+
tag: Optional[str] = None,
|
|
96
|
+
being: Optional[str] = None,
|
|
97
|
+
name: Optional[str] = None,
|
|
98
|
+
) -> List[FolderNature]:
|
|
99
|
+
"""Filter folder-natures under ``root`` by tag / being / name.
|
|
100
|
+
|
|
101
|
+
All filters are AND-combined. ``None`` for a filter means "don't filter".
|
|
102
|
+
Tag/name matching is substring + case-insensitive; ``being`` is exact match.
|
|
103
|
+
|
|
104
|
+
Returns the matching natures in walk order (depth-first).
|
|
105
|
+
"""
|
|
106
|
+
out: List[FolderNature] = []
|
|
107
|
+
tag_lower = tag.lower() if tag else None
|
|
108
|
+
name_lower = name.lower() if name else None
|
|
109
|
+
|
|
110
|
+
for nature in list_all(root):
|
|
111
|
+
if being is not None and nature.identity is not None:
|
|
112
|
+
if nature.identity.being != being:
|
|
113
|
+
continue
|
|
114
|
+
if tag_lower is not None:
|
|
115
|
+
if not any(tag_lower in t.lower() for t in nature.tags):
|
|
116
|
+
continue
|
|
117
|
+
if name_lower is not None and nature.identity is not None:
|
|
118
|
+
if name_lower not in nature.identity.name.lower():
|
|
119
|
+
continue
|
|
120
|
+
out.append(nature)
|
|
121
|
+
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def iter_nature_file_paths(root: Path) -> Iterator[Path]:
|
|
126
|
+
"""Public iterator over .folder-nature file paths (no validation).
|
|
127
|
+
|
|
128
|
+
Useful for the ``list`` and ``validate`` CLI commands which need paths
|
|
129
|
+
even for invalid files.
|
|
130
|
+
"""
|
|
131
|
+
yield from _iter_nature_files(root)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
schema_version: "1.0"
|
|
2
|
+
identity:
|
|
3
|
+
name: archive # TODO: customize via --name
|
|
4
|
+
being: collector
|
|
5
|
+
purpose: Historical / inactive artifacts retained for reference
|
|
6
|
+
director: false
|
|
7
|
+
tags:
|
|
8
|
+
- archive
|
|
9
|
+
- inactive
|
|
10
|
+
- reference
|
|
11
|
+
rules:
|
|
12
|
+
- Do not modify archived content; create a new working copy if changes needed
|
|
13
|
+
- Review for retention annually
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
schema_version: "1.0"
|
|
2
|
+
identity:
|
|
3
|
+
name: client-project # TODO: customize via --name
|
|
4
|
+
being: workspace
|
|
5
|
+
purpose: Client-specific project artifacts and deliverables
|
|
6
|
+
director: false
|
|
7
|
+
tags:
|
|
8
|
+
- client
|
|
9
|
+
- project
|
|
10
|
+
- active
|
|
11
|
+
rules:
|
|
12
|
+
- Sensitive client data — confirm sharing permissions before external distribution
|
|
13
|
+
- Archive after project handoff
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
schema_version: "1.0"
|
|
2
|
+
identity:
|
|
3
|
+
name: deployment # TODO: customize via --name
|
|
4
|
+
being: configs
|
|
5
|
+
purpose: Production-touching configuration and deploy scripts
|
|
6
|
+
director: false
|
|
7
|
+
tags:
|
|
8
|
+
- production
|
|
9
|
+
- deploy
|
|
10
|
+
- sensitive
|
|
11
|
+
rules:
|
|
12
|
+
- Only deploy scripts and verified-by-review configs live here
|
|
13
|
+
- Changes require explicit deploy approval; do not edit in-place during incidents
|
|
14
|
+
- Secrets never stored here in plaintext — use .env or secret manager references
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
schema_version: "1.0"
|
|
2
|
+
identity:
|
|
3
|
+
name: documentation # TODO: customize via --name
|
|
4
|
+
being: documentation
|
|
5
|
+
purpose: Project documentation, guides, and references
|
|
6
|
+
director: false
|
|
7
|
+
tags:
|
|
8
|
+
- docs
|
|
9
|
+
- reference
|
|
10
|
+
rules:
|
|
11
|
+
- Keep documentation current — update alongside the systems it describes
|
|
12
|
+
- Markdown preferred for portability
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
schema_version: "1.0"
|
|
2
|
+
identity:
|
|
3
|
+
name: workspace # TODO: customize via --name or replace
|
|
4
|
+
being: workspace
|
|
5
|
+
purpose: Active working directory for ongoing project work
|
|
6
|
+
director: false
|
|
7
|
+
tags:
|
|
8
|
+
- work
|
|
9
|
+
- active
|
|
10
|
+
rules:
|
|
11
|
+
- Files in this directory are work-in-progress; consider archiving stable artifacts
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: folder-nature
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Semantic identity for filesystem folders — folders become conversation partners
|
|
5
|
+
Author: Illia Hladkyi
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/myfjin/folder-nature
|
|
8
|
+
Project-URL: Repository, https://github.com/myfjin/folder-nature
|
|
9
|
+
Project-URL: Issues, https://github.com/myfjin/folder-nature/issues
|
|
10
|
+
Keywords: filesystem,metadata,semantic,organization,self-hosted
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: System :: Filesystems
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: PyYAML>=6.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# folder-nature
|
|
33
|
+
|
|
34
|
+
> *Folders are not containers. They're ecosystems with collective memory, personality, and rules.*
|
|
35
|
+
|
|
36
|
+
A directory becomes a conversation partner: it has identity, purpose, rules,
|
|
37
|
+
and history, all captured in a hidden `.folder-nature` YAML file at the
|
|
38
|
+
directory's root.
|
|
39
|
+
|
|
40
|
+
## What problem this solves
|
|
41
|
+
|
|
42
|
+
Filesystems don't carry meaning. `~/Projects/old_v3_final/` could be anything.
|
|
43
|
+
Search-by-filename doesn't help when you can't remember the filename. New team
|
|
44
|
+
members start from zero. AI assistants scan everything.
|
|
45
|
+
|
|
46
|
+
folder-nature attaches a small piece of YAML to each directory that captures:
|
|
47
|
+
- What this folder is for
|
|
48
|
+
- What kind of folder it is (workspace, archive, configs, ...)
|
|
49
|
+
- What tags apply
|
|
50
|
+
- What rules govern it
|
|
51
|
+
- When it was created and what notable things happened
|
|
52
|
+
|
|
53
|
+
Once attached, you can search by meaning, walk up to find the boss folder,
|
|
54
|
+
validate the tree, and let AI tools query a folder's intent before they
|
|
55
|
+
interact with its contents.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install git+https://github.com/myfjin/folder-nature.git
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires Python 3.10+. Single runtime dependency: PyYAML.
|
|
64
|
+
|
|
65
|
+
> **PyPI release planned for late July 2026.** Once published, install
|
|
66
|
+
> will simplify to `pip install folder-nature`.
|
|
67
|
+
|
|
68
|
+
## 5-minute tour
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Create something to tag
|
|
72
|
+
mkdir demo && cd demo
|
|
73
|
+
mkdir -p Old_Stuff RANDOM new_new_final download untitled_folder
|
|
74
|
+
|
|
75
|
+
# Tag the root
|
|
76
|
+
folder-nature init . --template workspace --name "demo-workspace"
|
|
77
|
+
|
|
78
|
+
# Show what got created
|
|
79
|
+
folder-nature show .
|
|
80
|
+
|
|
81
|
+
# Reorganize + tag the new structure
|
|
82
|
+
mkdir -p projects/{active,archive} clients/{acme,startup-x} documents
|
|
83
|
+
folder-nature init projects/active --template workspace
|
|
84
|
+
folder-nature init projects/archive --template archive
|
|
85
|
+
folder-nature init clients --template client-project --name "my-clients"
|
|
86
|
+
folder-nature init documents --template documentation
|
|
87
|
+
|
|
88
|
+
# Search by meaning
|
|
89
|
+
folder-nature search . --tag client
|
|
90
|
+
folder-nature search . --being collector
|
|
91
|
+
|
|
92
|
+
# Validate the tree
|
|
93
|
+
folder-nature validate .
|
|
94
|
+
|
|
95
|
+
# Tree view
|
|
96
|
+
folder-nature list .
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Templates (`init --template`)
|
|
100
|
+
|
|
101
|
+
Five archetypes ship with the package:
|
|
102
|
+
|
|
103
|
+
| Template | When to use |
|
|
104
|
+
|----------|-------------|
|
|
105
|
+
| `workspace` | Active project working directory |
|
|
106
|
+
| `client-project` | Client-specific work folder |
|
|
107
|
+
| `archive` | Historical / inactive storage |
|
|
108
|
+
| `deployment` | Production-touching configs and scripts |
|
|
109
|
+
| `documentation` | Docs, references, guides |
|
|
110
|
+
|
|
111
|
+
Customize at init time with `--name "your-name"` or edit the resulting
|
|
112
|
+
`.folder-nature` file directly afterward.
|
|
113
|
+
|
|
114
|
+
## Commands
|
|
115
|
+
|
|
116
|
+
| Command | What it does |
|
|
117
|
+
|---------|--------------|
|
|
118
|
+
| `folder-nature init [PATH] [--template T]` | Create a new `.folder-nature` |
|
|
119
|
+
| `folder-nature show [PATH]` | Print closest `.folder-nature` (walks up) |
|
|
120
|
+
| `folder-nature query [PATH]` | Find the `director: true` ancestor |
|
|
121
|
+
| `folder-nature search [ROOT] [--tag T] [--being B] [--name N]` | Filter folders by criteria |
|
|
122
|
+
| `folder-nature validate [ROOT]` | Schema-check all `.folder-nature` files in tree |
|
|
123
|
+
| `folder-nature list [ROOT]` | Tree view of every tagged folder |
|
|
124
|
+
| `folder-nature version` | Print tool + schema versions |
|
|
125
|
+
|
|
126
|
+
`PATH` and `ROOT` default to the current directory.
|
|
127
|
+
|
|
128
|
+
## Schema v1.0
|
|
129
|
+
|
|
130
|
+
A `.folder-nature` file is YAML with this shape:
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
schema_version: "1.0" # REQUIRED
|
|
134
|
+
|
|
135
|
+
identity: # REQUIRED
|
|
136
|
+
name: "Projects"
|
|
137
|
+
being: "director"
|
|
138
|
+
purpose: "Canonical root for all work"
|
|
139
|
+
|
|
140
|
+
director: true # OPTIONAL (default false)
|
|
141
|
+
|
|
142
|
+
tags: # OPTIONAL
|
|
143
|
+
- work
|
|
144
|
+
- canonical
|
|
145
|
+
- active
|
|
146
|
+
|
|
147
|
+
rules: # OPTIONAL
|
|
148
|
+
- "Only deployed code lives here"
|
|
149
|
+
|
|
150
|
+
memory: # OPTIONAL
|
|
151
|
+
created: "2026-05-09"
|
|
152
|
+
last_significant_change: "2026-06-22"
|
|
153
|
+
notable_events:
|
|
154
|
+
- "2026-05-09: initial setup"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The `identity.being` field accepts a controlled vocabulary plus arbitrary
|
|
158
|
+
custom values (extensibility hatch). Known types:
|
|
159
|
+
|
|
160
|
+
`director` · `collector` · `workspace` · `assets` · `configs` ·
|
|
161
|
+
`documentation` · `ideas` · `external` · `legal` · `team-shared` ·
|
|
162
|
+
`private` · `system`
|
|
163
|
+
|
|
164
|
+
## Design principles
|
|
165
|
+
|
|
166
|
+
1. **Files, not databases.** Each folder owns its own YAML. Git-friendly,
|
|
167
|
+
editor-friendly, no central store.
|
|
168
|
+
2. **Self-hosted.** Runs entirely local. No network calls. No telemetry.
|
|
169
|
+
3. **Schema-versioned.** Future schema bumps migrate cleanly.
|
|
170
|
+
4. **AI-readable.** Optional integration with LLM assistants — they query the
|
|
171
|
+
folder's nature before interacting with its contents.
|
|
172
|
+
5. **Open from day one.** MIT licensed. Code reviewable. Schema documented.
|
|
173
|
+
|
|
174
|
+
## Roadmap
|
|
175
|
+
|
|
176
|
+
- **v0.1 (this release):** core MVP — schema, CLI, 5 templates, tests
|
|
177
|
+
- **v0.2:** export/import, migration tool, more templates
|
|
178
|
+
- **v1.0:** AI integration (`folder-nature ai-suggest`), filesystem watcher,
|
|
179
|
+
comprehensive docs
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT. See [LICENSE](LICENSE).
|
|
184
|
+
|
|
185
|
+
## Development
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
git clone https://github.com/myfjin/folder-nature
|
|
189
|
+
cd folder-nature
|
|
190
|
+
python3 -m venv .venv
|
|
191
|
+
.venv/bin/pip install -e ".[dev]"
|
|
192
|
+
.venv/bin/pytest
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Test suite: 66 tests covering schema validation, filesystem operations,
|
|
196
|
+
search, and CLI integration. Should run in <1 second.
|
|
197
|
+
|
|
198
|
+
## Origin
|
|
199
|
+
|
|
200
|
+
folder-nature emerged from internal infrastructure work in early 2026 —
|
|
201
|
+
a working hypothesis that filesystems become more useful when directories
|
|
202
|
+
carry semantic identity. The concept proved itself on a 3-node operational
|
|
203
|
+
mesh before being extracted as this standalone tool.
|
|
204
|
+
|
|
205
|
+
🐍🦅💎
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
folder_nature/__init__.py,sha256=KSrS6U8W83igAbUQhZvDofuzE8nhJDJ3CwbP6Tq8x2k,1184
|
|
2
|
+
folder_nature/cli.py,sha256=dGGH-1ugxEx2OK-qm8l1V19iN7997nq__zpEAmv5Imk,11219
|
|
3
|
+
folder_nature/core.py,sha256=-ZZ-h8oj-AS5wTZyy5X6ZN_19LmzrXYBkPSF2mA1JP8,3758
|
|
4
|
+
folder_nature/schema.py,sha256=ZOAmAWSQL4E7Kdbox64V6Wv3cVKuXFom5UQ5ecSXHjY,9359
|
|
5
|
+
folder_nature/search.py,sha256=HNkvSoKuBVYXAXjm34FUWEWMCUyYCkRtH2ScTLIRJuA,4108
|
|
6
|
+
folder_nature/templates/archive.yaml,sha256=gBSGb-_VepoyULoicfDb-IwyeyjoUUu7r4Vij472OoQ,368
|
|
7
|
+
folder_nature/templates/client-project.yaml,sha256=Vu7OD_6Rjb9USdkPFJoPCnt7NJE138e2ZS-avbJoDvQ,366
|
|
8
|
+
folder_nature/templates/deployment.yaml,sha256=dvHUmI5QLUp_FStOOd7pfy-JVFkOlH2BW024XR2OOTA,485
|
|
9
|
+
folder_nature/templates/documentation.yaml,sha256=C1Y0d86Shlx4FlGFolHDDFFSqerIoAXk92Ru7kDd7So,349
|
|
10
|
+
folder_nature/templates/workspace.yaml,sha256=q8NSoeIh2NwVYWcQ2aXMyT-BHV8zWMbEJCQiHVundT0,327
|
|
11
|
+
folder_nature-0.1.0.dist-info/licenses/LICENSE,sha256=G4GyA00gWQieZa1oMRfToMkhsEhVn5cXpIwfPVMuhu4,1070
|
|
12
|
+
folder_nature-0.1.0.dist-info/METADATA,sha256=SZzvyKoe66JbhNCwLQwqYPkNowZQ84p2rNLMo36j_AE,6708
|
|
13
|
+
folder_nature-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
14
|
+
folder_nature-0.1.0.dist-info/entry_points.txt,sha256=ebYJv1BLDNz13dr5PZjFxl46retMjtblC-ZsmKQlnWQ,57
|
|
15
|
+
folder_nature-0.1.0.dist-info/top_level.txt,sha256=HGXz9UE9DVAnCt7sCkmWeVCvvS_loxTE3A4YyV5m9Ak,14
|
|
16
|
+
folder_nature-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Illia Hladkyi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
folder_nature
|