filekeeper-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ """Stable process exit codes.
2
+
3
+ These numbers are part of the published contract: scripts and agents may branch
4
+ on them. They are documented in ``README.md`` and must not be renumbered.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ..core.errors import ErrorCode, FilekeeperError
10
+
11
+ SUCCESS = 0
12
+ GENERAL_ERROR = 1
13
+ INVALID_ARGUMENT = 2
14
+ PATH_NOT_FOUND = 3
15
+ ACCESS_DENIED = 4
16
+ CONFLICT = 5
17
+ ABSOLUTE_PATH_OUTSIDE_WORKSPACE = 6
18
+ PATCH_FAILED = 7
19
+ PARTIAL_RECOVERY = 8
20
+ STORAGE_ERROR = 9
21
+ SENSITIVE_PATH_REFUSED = 10
22
+ SYMLINK_ESCAPE_REFUSED = 11
23
+ UNDO_CONFLICT = 12
24
+ HISTORY_UNAVAILABLE = 13
25
+
26
+ _BY_CODE: dict[str, int] = {
27
+ str(ErrorCode.INVALID_ARGUMENT): INVALID_ARGUMENT,
28
+ str(ErrorCode.NOT_A_FILE): INVALID_ARGUMENT,
29
+ str(ErrorCode.NOT_A_DIRECTORY): INVALID_ARGUMENT,
30
+ str(ErrorCode.LIMIT_EXCEEDED): INVALID_ARGUMENT,
31
+ str(ErrorCode.INVALID_PATTERN): INVALID_ARGUMENT,
32
+ str(ErrorCode.NO_WORKSPACE_SELECTED): INVALID_ARGUMENT,
33
+ str(ErrorCode.ALREADY_EXISTS): INVALID_ARGUMENT,
34
+ str(ErrorCode.BINARY_CONTENT_REFUSED): INVALID_ARGUMENT,
35
+ str(ErrorCode.PATH_NOT_FOUND): PATH_NOT_FOUND,
36
+ str(ErrorCode.WORKSPACE_NOT_FOUND): PATH_NOT_FOUND,
37
+ str(ErrorCode.PATH_OUTSIDE_WORKSPACE): ACCESS_DENIED,
38
+ str(ErrorCode.ABSOLUTE_PATH_OUTSIDE_WORKSPACE): ABSOLUTE_PATH_OUTSIDE_WORKSPACE,
39
+ str(ErrorCode.SYMLINK_ESCAPE_REFUSED): SYMLINK_ESCAPE_REFUSED,
40
+ str(ErrorCode.PATH_IS_STATE_PATH): ACCESS_DENIED,
41
+ str(ErrorCode.SYMLINK_WRITE_REFUSED): ACCESS_DENIED,
42
+ str(ErrorCode.SPECIAL_FILE_REFUSED): ACCESS_DENIED,
43
+ str(ErrorCode.SENSITIVE_PATH_REFUSED): SENSITIVE_PATH_REFUSED,
44
+ str(ErrorCode.CONTENT_CONFLICT): CONFLICT,
45
+ str(ErrorCode.NO_MATCH): CONFLICT,
46
+ str(ErrorCode.MATCH_COUNT_MISMATCH): CONFLICT,
47
+ str(ErrorCode.PATCH_FAILED): PATCH_FAILED,
48
+ str(ErrorCode.PARTIAL_RECOVERY): PARTIAL_RECOVERY,
49
+ str(ErrorCode.STORAGE_ERROR): STORAGE_ERROR,
50
+ str(ErrorCode.MIGRATION_ERROR): STORAGE_ERROR,
51
+ str(ErrorCode.OPERATION_NOT_FOUND): PATH_NOT_FOUND,
52
+ str(ErrorCode.UNDO_CONFLICT): UNDO_CONFLICT,
53
+ str(ErrorCode.HISTORY_UNAVAILABLE): HISTORY_UNAVAILABLE,
54
+ str(ErrorCode.WORKSPACE_EXISTS): INVALID_ARGUMENT,
55
+ str(ErrorCode.NOT_IMPLEMENTED_YET): GENERAL_ERROR,
56
+ str(ErrorCode.INTERNAL_ERROR): GENERAL_ERROR,
57
+ }
58
+
59
+ EXIT_CODE_DOCUMENTATION: tuple[tuple[int, str], ...] = (
60
+ (SUCCESS, "success"),
61
+ (GENERAL_ERROR, "unexpected error"),
62
+ (INVALID_ARGUMENT, "invalid arguments or validation error"),
63
+ (PATH_NOT_FOUND, "path or workspace not found"),
64
+ (ACCESS_DENIED, "permission or workspace-boundary violation"),
65
+ (CONFLICT, "concurrency conflict: stale hash, missing match or match-count mismatch"),
66
+ (ABSOLUTE_PATH_OUTSIDE_WORKSPACE, "absolute path outside the workspace"),
67
+ (PATCH_FAILED, "patch could not be applied; the pre-patch state is intact"),
68
+ (PARTIAL_RECOVERY, "a failed patch could not be fully rolled back"),
69
+ (STORAGE_ERROR, "storage or database error"),
70
+ (SENSITIVE_PATH_REFUSED, "write refused: target looks like credential material"),
71
+ (SYMLINK_ESCAPE_REFUSED, "symlink target outside the workspace"),
72
+ (UNDO_CONFLICT, "undo refused: the path changed after the operation"),
73
+ (HISTORY_UNAVAILABLE, "history needed for restore is missing, corrupt or skipped"),
74
+ )
75
+
76
+
77
+ def exit_code_for(error: FilekeeperError) -> int:
78
+ return _BY_CODE.get(str(error.code), GENERAL_ERROR)
79
+
80
+
81
+ def exit_code_for_code(code: str) -> int:
82
+ """Map a bare error-code string, for results that report failures as data."""
83
+
84
+ return _BY_CODE.get(code, GENERAL_ERROR)
@@ -0,0 +1,244 @@
1
+ """Rendering: human-readable text by default, one stable JSON object with --json.
2
+
3
+ Nothing here decides behaviour; it prints what Core returned. JSON payloads are
4
+ documented in ``README.md``: always an object, always with ``ok``, and errors
5
+ carry ``error.code`` / ``error.message`` / ``error.retryable``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import json
12
+ import sys
13
+ from enum import Enum
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from ..core.errors import FilekeeperError
18
+ from ..core.models import (
19
+ ContentSearchResult,
20
+ DirectoryListing,
21
+ ExistsResult,
22
+ FileReadResult,
23
+ MutationResult,
24
+ OperationDiff,
25
+ OperationRecord,
26
+ PathInfo,
27
+ PathSearchResult,
28
+ ReadManyEntry,
29
+ Workspace,
30
+ )
31
+
32
+
33
+ def jsonable(value: Any) -> Any:
34
+ """Convert dataclasses, enums, tuples and paths into JSON-ready values."""
35
+
36
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
37
+ fields = dataclasses.fields(value)
38
+ return {field.name: jsonable(getattr(value, field.name)) for field in fields}
39
+ if isinstance(value, Enum):
40
+ return value.value
41
+ if isinstance(value, Path):
42
+ return str(value)
43
+ if isinstance(value, dict):
44
+ return {str(key): jsonable(item) for key, item in value.items()}
45
+ if isinstance(value, (list, tuple)):
46
+ return [jsonable(item) for item in value]
47
+ return value
48
+
49
+
50
+ def print_json(payload: dict[str, Any]) -> None:
51
+ sys.stdout.write(json.dumps(jsonable(payload), ensure_ascii=False, indent=2, sort_keys=False))
52
+ sys.stdout.write("\n")
53
+
54
+
55
+ def print_error(error: FilekeeperError, *, as_json: bool) -> None:
56
+ if as_json:
57
+ print_json({"ok": False, "error": error.to_dict()})
58
+ return
59
+ sys.stderr.write(f"error: {error.code}: {error.message}\n")
60
+ for key, value in error.details.items():
61
+ sys.stderr.write(f" {key}: {value}\n")
62
+
63
+
64
+ def result_payload(result: Any) -> dict[str, Any]:
65
+ payload = jsonable(result)
66
+ merged: dict[str, Any] = {"ok": True}
67
+ merged.update(payload)
68
+ return merged
69
+
70
+
71
+ # -- human renderers -------------------------------------------------------
72
+ def read(result: FileReadResult) -> str:
73
+ return result.content
74
+
75
+
76
+ def read_many(entries: tuple[ReadManyEntry, ...]) -> str:
77
+ chunks: list[str] = []
78
+ for entry in entries:
79
+ chunks.append(f"== {entry.path} ==")
80
+ if entry.ok and entry.result is not None:
81
+ chunks.append(entry.result.content.rstrip("\n"))
82
+ else:
83
+ code = entry.error.code if entry.error else "UNKNOWN"
84
+ message = entry.error.message if entry.error else "read failed"
85
+ chunks.append(f"[{code}] {message}")
86
+ return "\n".join(chunks)
87
+
88
+
89
+ def listing(result: DirectoryListing) -> str:
90
+ lines: list[str] = []
91
+ for entry in result.entries:
92
+ marker = "/" if entry.kind == "directory" else "@" if entry.kind == "symlink" else ""
93
+ lines.append(f"{entry.kind:<9} {entry.size_bytes:>10} {entry.path}{marker}")
94
+ if result.truncated:
95
+ lines.append(f"... truncated at {result.max_entries} entries")
96
+ return "\n".join(lines)
97
+
98
+
99
+ def info(result: PathInfo) -> str:
100
+ lines = [
101
+ f"path: {result.path}",
102
+ f"kind: {result.kind}",
103
+ f"size_bytes: {result.size_bytes}",
104
+ f"mode: {result.mode:04o}",
105
+ f"modified_at: {result.modified_at}",
106
+ ]
107
+ if result.is_symlink:
108
+ lines.append(f"symlink_target: {result.symlink_target}")
109
+ if result.content_hash is not None:
110
+ lines.append(f"content_hash: {result.content_hash}")
111
+ if result.is_text is not None:
112
+ lines.append(f"is_text: {result.is_text}")
113
+ if result.line_count is not None:
114
+ lines.append(f"line_count: {result.line_count}")
115
+ if result.encoding is not None:
116
+ lines.append(f"encoding: {result.encoding}")
117
+ if result.newline is not None:
118
+ lines.append(f"newline: {json.dumps(result.newline)}")
119
+ return "\n".join(lines)
120
+
121
+
122
+ def exists(result: ExistsResult) -> str:
123
+ if not result.exists:
124
+ return f"{result.path}: absent"
125
+ return f"{result.path}: present ({result.kind})"
126
+
127
+
128
+ def path_search(result: PathSearchResult) -> str:
129
+ lines = list(result.paths)
130
+ if result.truncated:
131
+ lines.append("... truncated; narrow the glob or raise the result limit")
132
+ return "\n".join(lines)
133
+
134
+
135
+ def content_search(result: ContentSearchResult) -> str:
136
+ lines = [
137
+ f"{match.path}:{match.line_number}:{match.column}: {match.line}" for match in result.matches
138
+ ]
139
+ if result.truncated:
140
+ lines.append("... truncated; narrow the query or raise the result limit")
141
+ return "\n".join(lines)
142
+
143
+
144
+ def mutation(result: MutationResult) -> str:
145
+ lines: list[str] = []
146
+ for item in result.items:
147
+ lines.append(f"{item.change:<9} {item.path}")
148
+ lines.append(result.summary)
149
+ if result.after_hash:
150
+ lines.append(f"after_hash: {result.after_hash}")
151
+ if result.before_hash and result.before_hash != result.after_hash:
152
+ lines.append(f"before_hash: {result.before_hash}")
153
+ if result.match_stage is not None:
154
+ lines.append(f"match_stage: {result.match_stage}")
155
+ if result.match_count is not None:
156
+ lines.append(f"match_count: {result.match_count}")
157
+ if result.patch_files:
158
+ lines.append(
159
+ f"patch_files: {len(result.patch_files)} "
160
+ "(recoverable through history, not atomic across files)"
161
+ )
162
+ for file in result.patch_files:
163
+ hunks = ", ".join(
164
+ f"hunk {hunk.old_start}: {hunk.status}" for hunk in file.hunks
165
+ )
166
+ lines.append(f" {file.change:<9} {file.path} {hunks}")
167
+ if result.preview_diff:
168
+ lines.append("diff:")
169
+ lines.extend(result.preview_diff.rstrip("\n").split("\n"))
170
+ if result.operation_id is not None:
171
+ lines.append(f"operation_id: {result.operation_id}")
172
+ lines.append(f"undo_available: {'yes' if result.undo_available else 'no'}")
173
+ elif result.dry_run:
174
+ lines.append("operation_id: none (dry run: nothing was recorded)")
175
+ for warning in result.warnings:
176
+ lines.append(f"warning: {warning}")
177
+ return "\n".join(lines)
178
+
179
+
180
+ def history_list(operations: tuple[OperationRecord, ...]) -> str:
181
+ if not operations:
182
+ return "no recorded operations in this workspace"
183
+ lines = [
184
+ f"{record.operation_id} {record.created_at} {record.status:<9} "
185
+ f"{record.operation_type:<17} {record.target_path or '-'}"
186
+ + (f" [{record.actor}]" if record.actor else "")
187
+ for record in operations
188
+ ]
189
+ return "\n".join(lines)
190
+
191
+
192
+ def history_show(record: OperationRecord) -> str:
193
+ lines = [
194
+ f"operation_id: {record.operation_id}",
195
+ f"workspace_id: {record.workspace_id}",
196
+ f"operation_type: {record.operation_type}",
197
+ f"status: {record.status}",
198
+ f"created_at: {record.created_at}",
199
+ f"target_path: {record.target_path or '-'}",
200
+ f"actor: {record.actor or '-'}",
201
+ f"task_id: {record.task_id or '-'}",
202
+ f"summary: {record.summary or '-'}",
203
+ f"before_hash: {record.before_hash or '-'}",
204
+ f"after_hash: {record.after_hash or '-'}",
205
+ "items:",
206
+ ]
207
+ for item in record.items:
208
+ exists = f"{int(item.before_exists)}->{int(item.after_exists)}"
209
+ lines.append(
210
+ f" {item.item_type:<9} {item.relative_path} exists {exists} "
211
+ f"before {item.before_blob_hash or '-'} after {item.after_blob_hash or '-'}"
212
+ )
213
+ if record.history_skipped:
214
+ lines.append("history_skipped: " + ", ".join(record.history_skipped))
215
+ return "\n".join(lines)
216
+
217
+
218
+ def history_diff(diff: OperationDiff) -> str:
219
+ chunks: list[str] = []
220
+ for entry in diff.items:
221
+ if entry.is_binary:
222
+ chunks.append(f"# {entry.path}: binary content, no textual diff")
223
+ continue
224
+ if entry.unified_diff:
225
+ chunks.append(entry.unified_diff.rstrip("\n"))
226
+ return "\n".join(chunks) if chunks else "no textual differences recorded"
227
+
228
+
229
+ def workspace_summary(workspace: Workspace, *, is_default: bool = False) -> str:
230
+ suffix = " (default)" if is_default else ""
231
+ return f"{workspace.name}\t{workspace.root_path}\t{workspace.id}{suffix}"
232
+
233
+
234
+ def workspace_detail(workspace: Workspace, *, is_default: bool = False) -> str:
235
+ return "\n".join(
236
+ [
237
+ f"name: {workspace.name}",
238
+ f"id: {workspace.id}",
239
+ f"root: {workspace.root_path}",
240
+ f"created_at: {workspace.created_at}",
241
+ f"updated_at: {workspace.updated_at}",
242
+ f"default: {is_default}",
243
+ ]
244
+ )
@@ -0,0 +1,51 @@
1
+ """Filekeeper Core: workspace-confined, crash-safe filesystem operations.
2
+
3
+ Core imports nothing outside the Python standard library. It has no knowledge of
4
+ the CLI, of the MCP server, or of any filesystem protocol.
5
+ """
6
+
7
+ from .config import Config
8
+ from .errors import ErrorCode, FilekeeperError
9
+ from .history.blobs import BlobStore
10
+ from .models import (
11
+ ContentMatch,
12
+ ContentSearchResult,
13
+ DirectoryEntry,
14
+ DirectoryListing,
15
+ ErrorDetail,
16
+ ExistsResult,
17
+ FileReadResult,
18
+ MutationResult,
19
+ OperationItemResult,
20
+ PathInfo,
21
+ PathSearchResult,
22
+ ReadManyEntry,
23
+ Workspace,
24
+ )
25
+ from .service import Filekeeper, FilekeeperService, init_home
26
+ from .workspace import WorkspaceHandle, WorkspaceRegistry
27
+
28
+ __all__ = [
29
+ "BlobStore",
30
+ "Config",
31
+ "ContentMatch",
32
+ "ContentSearchResult",
33
+ "DirectoryEntry",
34
+ "DirectoryListing",
35
+ "ErrorCode",
36
+ "ErrorDetail",
37
+ "ExistsResult",
38
+ "FileReadResult",
39
+ "Filekeeper",
40
+ "FilekeeperError",
41
+ "FilekeeperService",
42
+ "MutationResult",
43
+ "OperationItemResult",
44
+ "PathInfo",
45
+ "PathSearchResult",
46
+ "ReadManyEntry",
47
+ "Workspace",
48
+ "WorkspaceHandle",
49
+ "WorkspaceRegistry",
50
+ "init_home",
51
+ ]
@@ -0,0 +1,133 @@
1
+ """Home directory resolution and configuration.
2
+
3
+ Two kinds of state exist, and they live in different places on purpose:
4
+
5
+ * the **registry** (workspace registrations and settings) lives in the user's
6
+ home, because it spans workspaces;
7
+ * **per-workspace state** (the future operation journal and blob store) lives in
8
+ ``<workspace>/.filekeeper/`` by default, so it travels with the checkout.
9
+ ``state_location = "global"`` opts into one shared store under the home
10
+ directory instead.
11
+
12
+ ``config.toml`` in the home directory holds tunable limits. It is read on every
13
+ invocation; writing it is left to the user (``keeper init`` creates it with the
14
+ defaults spelled out).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import tomllib
21
+ from dataclasses import dataclass, fields
22
+ from pathlib import Path
23
+
24
+ from .errors import StorageError, ValidationError
25
+
26
+ CONFIG_FILENAME = "config.toml"
27
+ STATE_DIR_NAME = ".filekeeper"
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Config:
32
+ """Limits and behaviour switches. Values are bytes unless stated otherwise."""
33
+
34
+ max_read_bytes: int = 5 * 1024 * 1024
35
+ max_write_bytes: int = 20 * 1024 * 1024
36
+ max_search_results: int = 500
37
+ max_directory_entries: int = 2000
38
+ max_recursion_depth: int = 25
39
+ max_line_length: int = 2000
40
+ max_history_blob_bytes: int = 4 * 1024 * 1024
41
+ fsync: bool = True
42
+ state_location: str = "project"
43
+
44
+ def __post_init__(self) -> None:
45
+ if self.state_location not in {"project", "global"}:
46
+ raise ValidationError(
47
+ "state_location must be 'project' or 'global'",
48
+ details={"state_location": self.state_location},
49
+ )
50
+ for field_info in fields(self):
51
+ value = getattr(self, field_info.name)
52
+ if isinstance(value, int) and not isinstance(value, bool) and value <= 0:
53
+ raise ValidationError(
54
+ f"{field_info.name} must be positive", details={"value": value}
55
+ )
56
+
57
+
58
+ def resolve_home(override: str | os.PathLike[str] | None = None) -> Path:
59
+ """Resolve Filekeeper's home directory: explicit, ``FILEKEEPER_HOME``, XDG, then default."""
60
+
61
+ if override is not None:
62
+ return Path(override).expanduser()
63
+ from_env = os.environ.get("FILEKEEPER_HOME")
64
+ if from_env:
65
+ return Path(from_env).expanduser()
66
+ xdg = os.environ.get("XDG_DATA_HOME")
67
+ if xdg:
68
+ return Path(xdg).expanduser() / "filekeeper"
69
+ return Path.home() / ".local" / "share" / "filekeeper"
70
+
71
+
72
+ def config_path(home: Path) -> Path:
73
+ return home / CONFIG_FILENAME
74
+
75
+
76
+ def load_config(home: Path) -> Config:
77
+ """Load ``config.toml``; a missing file yields defaults. Unknown keys are ignored."""
78
+
79
+ path = config_path(home)
80
+ if not path.exists():
81
+ return Config()
82
+ try:
83
+ with path.open("rb") as handle:
84
+ raw = tomllib.load(handle)
85
+ except (OSError, tomllib.TOMLDecodeError) as exc:
86
+ raise StorageError(
87
+ f"cannot read configuration file: "
88
+ f"{getattr(exc, 'strerror', None) or exc}",
89
+ details={"path": str(path)},
90
+ ) from exc
91
+
92
+ section = raw.get("filekeeper", raw)
93
+ if not isinstance(section, dict):
94
+ raise StorageError("configuration file must contain a table", details={"path": str(path)})
95
+
96
+ known = {field_info.name for field_info in fields(Config)}
97
+ values: dict[str, object] = {}
98
+ for key, value in section.items():
99
+ if key in known:
100
+ values[key] = value
101
+ try:
102
+ return Config(**values) # type: ignore[arg-type]
103
+ except TypeError as exc:
104
+ raise StorageError(
105
+ f"invalid configuration value: {exc}", details={"path": str(path)}
106
+ ) from exc
107
+
108
+
109
+ DEFAULT_CONFIG_TOML = """\
110
+ # Filekeeper configuration.
111
+ # All sizes are bytes. Delete a key to fall back to the built-in default.
112
+
113
+ [filekeeper]
114
+ max_read_bytes = 5242880
115
+ max_write_bytes = 20971520
116
+ max_search_results = 500
117
+ max_directory_entries = 2000
118
+ max_recursion_depth = 25
119
+ max_line_length = 2000
120
+
121
+ # Files above this size are skipped by history: the mutation is still recorded,
122
+ # but its previous bytes are not stored, so it cannot be undone. This keeps one
123
+ # large edit from silently filling the blob store.
124
+ max_history_blob_bytes = 4194304
125
+
126
+ # Durability of atomic writes: flush and fsync the temporary file and the
127
+ # parent directory before reporting success.
128
+ fsync = true
129
+
130
+ # "project" keeps per-workspace state in <workspace>/.filekeeper/ (default).
131
+ # "global" keeps one shared store under this home directory instead.
132
+ state_location = "project"
133
+ """