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.
@@ -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
@@ -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")
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ folder-nature = folder_nature.cli:main
@@ -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