endpaper 0.0.1__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.
endpaper/core/tasks.py ADDED
@@ -0,0 +1,434 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import tempfile
6
+ from collections.abc import Iterable, Sequence
7
+ from dataclasses import replace
8
+ from datetime import date, datetime
9
+ from pathlib import Path
10
+
11
+ from endpaper.core.documents import _TOKEN_PATTERN, _validate_token
12
+ from endpaper.core.errors import NotFoundError, UsageError, WorkspaceError
13
+ from endpaper.core.models import ParsedTasks, ScanWarning, Task, TaskFilter, Workspace
14
+ from endpaper.core.text import new_task_id, parse_tags
15
+
16
+ _TASK_LINE = re.compile(
17
+ r"^(?P<indent>[ \t]*)(?P<marker>[-*+])[ \t]+\[(?P<state>[ xX])\][ \t]+(?P<rest>.*)$"
18
+ )
19
+ _IDVAL = re.compile(r"^[A-Za-z0-9_-]+$")
20
+ _ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
21
+ _RECOGNIZED_KEYS = frozenset({"id", "type", "tags", "created"})
22
+ _TERMINATORS = ("\r\n", "\n", "\r")
23
+
24
+ TASKS_PATH = Path("tasks.md")
25
+
26
+
27
+ def _split_terminator(line: str) -> tuple[str, str]:
28
+ for terminator in _TERMINATORS:
29
+ if line.endswith(terminator):
30
+ return line[: -len(terminator)], terminator
31
+ return line, ""
32
+
33
+
34
+ def _split_comment(rest: str) -> tuple[str, str | None, bool]:
35
+ """Locate the last ``<!-- ... -->`` on the line, if any.
36
+
37
+ Returns (text_before, body, unterminated). `body` is None when there is no
38
+ `<!--` at all. `unterminated` is True when a `<!--` is opened but never closed.
39
+ """
40
+ idx = rest.rfind("<!--")
41
+ if idx == -1:
42
+ return rest, None, False
43
+ end = rest.find("-->", idx + 4)
44
+ if end == -1:
45
+ return rest, None, True
46
+ return rest[:idx], rest[idx + 4 : end], False
47
+
48
+
49
+ def _classify_body(body: str) -> tuple[str, dict[str, str]]:
50
+ """Classify a closed comment's contents as bare / malformed / task."""
51
+ tokens = body.split()
52
+ fields: dict[str, str] = {}
53
+ has_recognized = False
54
+ has_unknown = False
55
+
56
+ for token in tokens:
57
+ key, sep, value = token.partition(":")
58
+ if sep and key in _RECOGNIZED_KEYS:
59
+ has_recognized = True
60
+ fields[key] = value
61
+ else:
62
+ has_unknown = True
63
+
64
+ if not has_recognized:
65
+ return "bare", {}
66
+ if has_unknown:
67
+ return "malformed", {}
68
+
69
+ if "id" in fields and not _IDVAL.match(fields["id"]):
70
+ return "malformed", {}
71
+ if "type" in fields and not _TOKEN_PATTERN.match(fields["type"]):
72
+ return "malformed", {}
73
+ if "tags" in fields:
74
+ tag_values = fields["tags"].split(",") if fields["tags"] else []
75
+ if not tag_values or any(not _TOKEN_PATTERN.match(tag) for tag in tag_values):
76
+ return "malformed", {}
77
+
78
+ return "task", fields
79
+
80
+
81
+ def parse_tasks(text: str) -> ParsedTasks:
82
+ """Classify every line of a tasks.md document.
83
+
84
+ Never raises. Malformed lines become warnings, not exceptions.
85
+ ``"".join(result.lines) == text`` for any input.
86
+ """
87
+ raw_lines = text.splitlines(keepends=True)
88
+ tasks: list[Task] = []
89
+ warnings: list[ScanWarning] = []
90
+ needs_id: list[int] = []
91
+
92
+ for idx, raw_line in enumerate(raw_lines):
93
+ line_no = idx + 1
94
+ content, _terminator = _split_terminator(raw_line)
95
+
96
+ match = _TASK_LINE.match(content)
97
+ if match is None:
98
+ continue
99
+
100
+ rest = match.group("rest")
101
+ done = match.group("state") in ("x", "X")
102
+
103
+ before, body, unterminated = _split_comment(rest)
104
+
105
+ if body is None and not unterminated:
106
+ tasks.append(
107
+ Task(
108
+ id=None,
109
+ text=rest.rstrip(),
110
+ done=done,
111
+ type="",
112
+ tags=(),
113
+ created=None,
114
+ line=line_no,
115
+ )
116
+ )
117
+ needs_id.append(idx)
118
+ continue
119
+
120
+ if unterminated:
121
+ warnings.append(
122
+ ScanWarning(
123
+ path=TASKS_PATH,
124
+ reason="task_unterminated_comment",
125
+ message=f"tasks.md:{line_no}: unterminated comment on a task line",
126
+ )
127
+ )
128
+ continue
129
+
130
+ assert body is not None
131
+ classification, fields = _classify_body(body)
132
+
133
+ if classification == "bare":
134
+ tasks.append(
135
+ Task(
136
+ id=None,
137
+ text=rest.rstrip(),
138
+ done=done,
139
+ type="",
140
+ tags=(),
141
+ created=None,
142
+ line=line_no,
143
+ )
144
+ )
145
+ needs_id.append(idx)
146
+ continue
147
+
148
+ if classification == "malformed":
149
+ warnings.append(
150
+ ScanWarning(
151
+ path=TASKS_PATH,
152
+ reason="task_malformed_comment",
153
+ message=f"tasks.md:{line_no}: malformed metadata comment on a task line",
154
+ )
155
+ )
156
+ continue
157
+
158
+ task_id = fields.get("id")
159
+ task_type = fields.get("type", "")
160
+ tags_raw = fields.get("tags", "")
161
+ tags = tuple(tags_raw.split(",")) if tags_raw else ()
162
+
163
+ created_raw = fields.get("created")
164
+ created_value: date | None = None
165
+ if created_raw:
166
+ if _ISO_DATE.match(created_raw):
167
+ try:
168
+ created_value = date.fromisoformat(created_raw)
169
+ except ValueError:
170
+ created_value = None
171
+ if created_value is None:
172
+ warnings.append(
173
+ ScanWarning(
174
+ path=TASKS_PATH,
175
+ reason="task_invalid_value",
176
+ message=f"tasks.md:{line_no}: invalid created date {created_raw!r}",
177
+ )
178
+ )
179
+
180
+ tasks.append(
181
+ Task(
182
+ id=task_id,
183
+ text=before.rstrip(),
184
+ done=done,
185
+ type=task_type,
186
+ tags=tags,
187
+ created=created_value,
188
+ line=line_no,
189
+ )
190
+ )
191
+
192
+ return ParsedTasks(
193
+ tasks=tuple(tasks),
194
+ warnings=tuple(warnings),
195
+ lines=tuple(raw_lines),
196
+ needs_id=tuple(needs_id),
197
+ )
198
+
199
+
200
+ def _render_comment(
201
+ *, id: str, type: str = "", tags: Sequence[str] = (), created: date | None = None
202
+ ) -> str:
203
+ fields = [f"id:{id}"]
204
+ if type:
205
+ fields.append(f"type:{type}")
206
+ if tags:
207
+ fields.append(f"tags:{','.join(tags)}")
208
+ if created is not None:
209
+ fields.append(f"created:{created.isoformat()}")
210
+ return f"<!-- {' '.join(fields)} -->"
211
+
212
+
213
+ def render_task_line(
214
+ text: str,
215
+ *,
216
+ done: bool = False,
217
+ id: str,
218
+ type: str = "",
219
+ tags: Sequence[str] = (),
220
+ created: date | None = None,
221
+ ) -> str:
222
+ """Render one task line, without a terminator.
223
+
224
+ Fields appear in the order id, type, tags, created; empty ones are omitted.
225
+ Raises UsageError if `text` is empty after stripping.
226
+ """
227
+ stripped = text.strip()
228
+ if not stripped:
229
+ raise UsageError("task text must not be empty")
230
+ checkbox = "x" if done else " "
231
+ comment = _render_comment(id=id, type=type, tags=tags, created=created)
232
+ return f"- [{checkbox}] {stripped} {comment}"
233
+
234
+
235
+ def filter_tasks(tasks: Iterable[Task], f: TaskFilter) -> list[Task]:
236
+ """Conjunctive filter. Sorts oldest-first, undated last, stable within a date."""
237
+ results: list[Task] = []
238
+ for task in tasks:
239
+ if not f.include_done and task.done:
240
+ continue
241
+ if f.type is not None and task.type.lower() != f.type.lower():
242
+ continue
243
+ if f.tags:
244
+ task_tags = {tag.lower() for tag in task.tags}
245
+ if not all(tag.lower() in task_tags for tag in f.tags):
246
+ continue
247
+ results.append(task)
248
+ return sorted(results, key=lambda t: (t.created is None, t.created or date.min))
249
+
250
+
251
+ def match_task(task: Task, query: str) -> bool:
252
+ """Case-insensitive substring over text, type, and tags. For the TUI's live filter."""
253
+ haystack = " ".join([task.text, task.type, *task.tags]).lower()
254
+ return query.lower() in haystack
255
+
256
+
257
+ def _atomic_write(path: Path, lines: Sequence[str]) -> None:
258
+ try:
259
+ path.parent.mkdir(parents=True, exist_ok=True)
260
+ fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
261
+ tmp_path = Path(tmp_name)
262
+ try:
263
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
264
+ fh.write("".join(lines))
265
+ os.replace(tmp_path, path)
266
+ except BaseException:
267
+ tmp_path.unlink(missing_ok=True)
268
+ raise
269
+ except (PermissionError, OSError) as exc:
270
+ raise WorkspaceError(f"could not write {path}: {exc}") from exc
271
+
272
+
273
+ def _read_text(path: Path) -> str:
274
+ try:
275
+ with open(path, encoding="utf-8", newline="") as fh:
276
+ return fh.read()
277
+ except OSError as exc:
278
+ raise WorkspaceError(f"could not read {path}: {exc}") from exc
279
+
280
+
281
+ def load_tasks(workspace: Workspace) -> tuple[list[Task], list[ScanWarning]]:
282
+ """Read tasks.md, backfill missing identifiers in place, return records.
283
+
284
+ A missing tasks.md is an empty list, not an error. Backfill is best-effort: if
285
+ the file cannot be written, the affected tasks are returned with id=None plus
286
+ a warning, and the read still succeeds (FR-038).
287
+ """
288
+ path = workspace.tasks_file
289
+ if not path.exists():
290
+ return [], []
291
+
292
+ text = _read_text(path)
293
+ parsed = parse_tasks(text)
294
+ warnings = [replace(w, path=path) for w in parsed.warnings]
295
+ tasks = list(parsed.tasks)
296
+
297
+ if not parsed.needs_id:
298
+ return tasks, warnings
299
+
300
+ taken = {t.id for t in tasks if t.id is not None}
301
+ lines = list(parsed.lines)
302
+ id_map: dict[int, str] = {}
303
+ for idx in parsed.needs_id:
304
+ new_id = new_task_id(taken)
305
+ taken.add(new_id)
306
+ id_map[idx] = new_id
307
+ content, terminator = _split_terminator(lines[idx])
308
+ lines[idx] = f"{content} <!-- id:{new_id} -->{terminator}"
309
+
310
+ try:
311
+ _atomic_write(path, lines)
312
+ except WorkspaceError as exc:
313
+ warnings.append(
314
+ ScanWarning(
315
+ path=path,
316
+ reason="task_invalid_value",
317
+ message=f"could not backfill task ids: {exc}",
318
+ )
319
+ )
320
+ return tasks, warnings
321
+
322
+ return [
323
+ replace(task, id=id_map[task.line - 1]) if (task.line - 1) in id_map else task
324
+ for task in tasks
325
+ ], warnings
326
+
327
+
328
+ def add_task(
329
+ workspace: Workspace,
330
+ description: str,
331
+ *,
332
+ type: str = "",
333
+ tags: Sequence[str] = (),
334
+ now: datetime | None = None,
335
+ ) -> Task:
336
+ """Append one task to tasks.md and return it.
337
+
338
+ Parses inline #tags out of `description` and merges them after `tags`, exactly
339
+ as create_document does. Creates tasks.md if absent. Every pre-existing byte is
340
+ preserved; if the file did not end with a newline, the terminator is added.
341
+ """
342
+ when = now or datetime.now()
343
+ title, inline_tags = parse_tags(description)
344
+ if not title:
345
+ raise UsageError("description must not be empty after removing #tag tokens")
346
+
347
+ normalized_type = _validate_token(type, "type") if type else ""
348
+ merged_tags: list[str] = []
349
+ for tag in (*tags, *inline_tags):
350
+ normalized = _validate_token(tag, "tag")
351
+ if normalized not in merged_tags:
352
+ merged_tags.append(normalized)
353
+
354
+ path = workspace.tasks_file
355
+ existing_text = _read_text(path) if path.exists() else ""
356
+ parsed = parse_tasks(existing_text)
357
+ taken = {t.id for t in parsed.tasks if t.id is not None}
358
+ new_id = new_task_id(taken)
359
+
360
+ rendered = render_task_line(
361
+ title,
362
+ done=False,
363
+ id=new_id,
364
+ type=normalized_type,
365
+ tags=tuple(merged_tags),
366
+ created=when.date(),
367
+ )
368
+
369
+ lines = list(parsed.lines)
370
+ if lines:
371
+ last_content, last_terminator = _split_terminator(lines[-1])
372
+ if not last_terminator:
373
+ lines[-1] = last_content + "\n"
374
+ lines.append(rendered + "\n")
375
+
376
+ _atomic_write(path, lines)
377
+
378
+ return Task(
379
+ id=new_id,
380
+ text=title,
381
+ done=False,
382
+ type=normalized_type,
383
+ tags=tuple(merged_tags),
384
+ created=when.date(),
385
+ line=len(lines),
386
+ )
387
+
388
+
389
+ def _format_line_numbers(numbers: Sequence[int]) -> str:
390
+ if len(numbers) == 2:
391
+ return f"{numbers[0]} and {numbers[1]}"
392
+ return ", ".join(str(n) for n in numbers[:-1]) + f", and {numbers[-1]}"
393
+
394
+
395
+ def set_task_state(workspace: Workspace, task_id: str, *, done: bool) -> Task:
396
+ """Set one task's checkbox and return the task as it now stands.
397
+
398
+ Re-reads and re-parses before writing; locates by id, never by a cached line
399
+ number. Changes exactly one character on that line. No-op (no write) if the
400
+ task already has the requested state.
401
+ """
402
+ path = workspace.tasks_file
403
+ if not path.exists():
404
+ raise NotFoundError(f"no task with id {task_id!r}")
405
+
406
+ text = _read_text(path)
407
+ parsed = parse_tasks(text)
408
+ matches = [t for t in parsed.tasks if t.id == task_id]
409
+
410
+ if not matches:
411
+ raise NotFoundError(f"no task with id {task_id!r}")
412
+ if len(matches) > 1:
413
+ line_numbers = _format_line_numbers([t.line for t in matches])
414
+ raise UsageError(
415
+ f"id {task_id!r} appears on lines {line_numbers}; "
416
+ "edit tasks.md to give one of them a different id"
417
+ )
418
+
419
+ task = matches[0]
420
+ if task.done == done:
421
+ return task
422
+
423
+ lines = list(parsed.lines)
424
+ idx = task.line - 1
425
+ content, terminator = _split_terminator(lines[idx])
426
+ match = _TASK_LINE.match(content)
427
+ assert match is not None
428
+ state_start, state_end = match.span("state")
429
+ new_char = "x" if done else " "
430
+ lines[idx] = content[:state_start] + new_char + content[state_end:] + terminator
431
+
432
+ _atomic_write(path, lines)
433
+
434
+ return replace(task, done=done)
@@ -0,0 +1,62 @@
1
+ # endpaper workspace
2
+
3
+ Plain Markdown files with YAML frontmatter, no database. Every command below is
4
+ non-interactive and produces stable, parseable output.
5
+
6
+ ## Layout
7
+
8
+ ```
9
+ .endpaper/config.toml workspace marker, do not edit
10
+ meetings/YYYY/MM/ one markdown file per meeting
11
+ notes/YYYY/MM/ one markdown file per typed or untyped note
12
+ notes/daily/YYYY/MM/ one markdown file per day, named YYYY-MM-DD.md
13
+ tasks.md one checkbox line per task, at the workspace root
14
+ AGENTS.md this file
15
+ ```
16
+
17
+ ## Frontmatter
18
+
19
+ Every file in `meetings/` and `notes/` starts with exactly these six keys:
20
+
21
+ ```yaml
22
+ ---
23
+ id: m_20260728_a1b2c3d4
24
+ type: "standup"
25
+ title: "Q3 planning"
26
+ tags: ["platform"]
27
+ created: 2026-07-28T09:14:00
28
+ updated: 2026-07-28T09:14:00
29
+ ---
30
+ ```
31
+
32
+ `id` is prefixed `m_` for meetings, `n_` for notes. `type` is `""` when untyped; `tags` is `[]`.
33
+
34
+ ## Tasks
35
+
36
+ One checkbox line per task in `tasks.md`, metadata in a trailing HTML comment (invisible in
37
+ any markdown viewer). `[ ]` open, `[x]` done; a bare `- [ ] ...` gets an `id` on the next scan:
38
+
39
+ ```
40
+ - [ ] send the vendor comparison <!-- id:t_a1b2 type:followup tags:procurement created:2026-07-28 -->
41
+ ```
42
+
43
+ ## Commands
44
+
45
+ ```bash
46
+ endpaper meeting new "Q3 planning" --type standup --tag platform
47
+ endpaper meeting list --json --type standup --tag platform --since 2026-07-01
48
+ endpaper note today # open/create today's note
49
+ endpaper note new "vendor landscape" --type research --tag procurement
50
+ endpaper note list --json --type daily --since 2026-07-01
51
+ endpaper task add "send the vendor comparison" --type followup --tag procurement
52
+ endpaper task list --json --all --type followup --tag procurement
53
+ endpaper task done t_a1b2 # endpaper task undone t_a1b2
54
+ ```
55
+
56
+ `--tag` is repeatable and the supported way to attach tags on the command line -- an unquoted
57
+ `#tag` is eaten by the shell first; a `#tag` inside a *quoted* description is parsed out too.
58
+
59
+ ## Exit codes
60
+
61
+ `0` success · `1` not found · `2` usage error · `3` workspace error. Diagnostics go to
62
+ stderr; data goes to stdout; the two are never interleaved.
@@ -0,0 +1,8 @@
1
+ # endpaper workspace
2
+
3
+ This directory is an endpaper workspace. Before creating or changing anything here, read
4
+ `AGENTS.md` in this same directory — it documents the commands, the layout, and the file
5
+ format this tool expects.
6
+
7
+ The one thing worth stating twice: create new files through the commands in `AGENTS.md`,
8
+ not by hand. Once a file exists, edit its Markdown body directly like any other file.
endpaper/core/text.py ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import secrets
5
+ import unicodedata
6
+ from collections.abc import Container
7
+ from datetime import date
8
+
9
+ _NON_ALNUM = re.compile(r"[^a-z0-9]+")
10
+ _TAG_TOKEN = re.compile(r"#([A-Za-z0-9][A-Za-z0-9_-]*)")
11
+ _WHITESPACE = re.compile(r"\s+")
12
+
13
+
14
+ def slugify(text: str, *, max_length: int = 40) -> str:
15
+ normalized = unicodedata.normalize("NFKD", text)
16
+ without_marks = "".join(ch for ch in normalized if not unicodedata.combining(ch))
17
+ casefolded = without_marks.casefold()
18
+ collapsed = _NON_ALNUM.sub("-", casefolded)
19
+ stripped = collapsed.strip("-")
20
+ truncated = stripped[:max_length].rstrip("-")
21
+ return truncated or "untitled"
22
+
23
+
24
+ def parse_tags(description: str) -> tuple[str, tuple[str, ...]]:
25
+ tags: list[str] = []
26
+ for match in _TAG_TOKEN.finditer(description):
27
+ tag = match.group(1).lower()
28
+ if tag not in tags:
29
+ tags.append(tag)
30
+
31
+ title = _TAG_TOKEN.sub(" ", description)
32
+ title = _WHITESPACE.sub(" ", title).strip()
33
+ return title, tuple(tags)
34
+
35
+
36
+ def new_document_id(when: date, prefix: str) -> str:
37
+ return f"{prefix}{when:%Y%m%d}_{secrets.token_hex(4)}"
38
+
39
+
40
+ def new_meeting_id(when: date) -> str:
41
+ return new_document_id(when, "m_")
42
+
43
+
44
+ def new_task_id(taken: Container[str]) -> str:
45
+ while True:
46
+ candidate = f"t_{secrets.token_hex(2)}"
47
+ if candidate not in taken:
48
+ return candidate
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tomllib
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from endpaper.core.errors import WorkspaceError
9
+ from endpaper.core.models import InitResult, Workspace
10
+
11
+ SUPPORTED_SCHEMA = 1
12
+ _TEMPLATES_DIR = Path(__file__).parent / "templates"
13
+ _GUIDANCE_TEMPLATES = {
14
+ "AGENTS.md": _TEMPLATES_DIR / "AGENTS.md.tmpl",
15
+ "CLAUDE.md": _TEMPLATES_DIR / "CLAUDE.md.tmpl",
16
+ }
17
+
18
+
19
+ def find_workspace(start: Path) -> Workspace:
20
+ current = start.resolve()
21
+ while True:
22
+ marker = current / ".endpaper" / "config.toml"
23
+ if marker.is_file():
24
+ _check_schema(marker)
25
+ return Workspace(root=current)
26
+ parent = current.parent
27
+ if parent == current:
28
+ break
29
+ current = parent
30
+ raise WorkspaceError(
31
+ "no workspace found in this directory or any parent. Run 'endpaper init' to create one."
32
+ )
33
+
34
+
35
+ def _check_schema(marker: Path) -> None:
36
+ with marker.open("rb") as f:
37
+ data = tomllib.load(f)
38
+ schema = data.get("workspace", {}).get("schema")
39
+ if schema != SUPPORTED_SCHEMA:
40
+ raise WorkspaceError(
41
+ f"unsupported workspace schema {schema!r} in {marker}; "
42
+ f"this build only supports schema {SUPPORTED_SCHEMA}"
43
+ )
44
+
45
+
46
+ def _write_guidance_file(target: Path, name: str) -> bool:
47
+ """Write one guidance file if absent. Returns True when written, False when an
48
+ existing file was left untouched. Uses O_EXCL so the guarantee is enforced by the
49
+ OS rather than a check-then-write race."""
50
+ template = _GUIDANCE_TEMPLATES[name].read_text(encoding="utf-8")
51
+ try:
52
+ fd = os.open(target / name, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
53
+ except FileExistsError:
54
+ return False
55
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
56
+ fh.write(template)
57
+ return True
58
+
59
+
60
+ def init_workspace(target: Path) -> InitResult:
61
+ target = target.resolve()
62
+ endpaper_dir = target / ".endpaper"
63
+ if endpaper_dir.exists():
64
+ raise WorkspaceError(f"this directory is already an endpaper workspace: {target}")
65
+
66
+ (target / "meetings").mkdir(parents=True, exist_ok=True)
67
+ (target / "notes" / "daily").mkdir(parents=True, exist_ok=True)
68
+ (target / "tasks.md").touch(exist_ok=True)
69
+
70
+ written: list[str] = []
71
+ skipped: list[str] = []
72
+ for name in ("AGENTS.md", "CLAUDE.md"):
73
+ if _write_guidance_file(target, name):
74
+ written.append(name)
75
+ else:
76
+ skipped.append(name)
77
+
78
+ endpaper_dir.mkdir(parents=True, exist_ok=True)
79
+ now = datetime.now().replace(microsecond=0).isoformat()
80
+ (endpaper_dir / "config.toml").write_text(
81
+ f'[workspace]\nschema = {SUPPORTED_SCHEMA}\ncreated = "{now}"\n',
82
+ encoding="utf-8",
83
+ )
84
+
85
+ return InitResult(
86
+ workspace=Workspace(root=target), written=tuple(written), skipped=tuple(skipped)
87
+ )
File without changes