filekeeper-cli 0.1.3__tar.gz → 0.1.4__tar.gz
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.
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/PKG-INFO +7 -1
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/README.md +6 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/pyproject.toml +1 -1
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/pyproject.toml.orig +1 -1
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/__init__.py +1 -1
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/config.py +14 -4
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/database.py +17 -8
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/service.py +9 -6
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/workspace.py +37 -7
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/LICENSE +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/cli/__init__.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/cli/app.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/cli/exit_codes.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/cli/output.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/__init__.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/diffing.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/errors.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/__init__.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/blobs.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/journal.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/repository.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/undo.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/models.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/__init__.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/directory.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/edit.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/read.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/search.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/write.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/paths.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/__init__.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/atomic.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/hashing.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/sensitive.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/textdata.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/vcs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: filekeeper-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement.
|
|
5
5
|
Keywords: filesystem,atomic-write,cli,agents,mcp
|
|
6
6
|
Author: sangingeum
|
|
@@ -150,6 +150,12 @@ home directory instead. The trade-off: history survives deleting the checkout, b
|
|
|
150
150
|
per-project attribution and pruning become harder, and undo history no longer
|
|
151
151
|
travels with the tree.
|
|
152
152
|
|
|
153
|
+
If the workspace root cannot host `.filekeeper/` — a read-only checkout, or a root
|
|
154
|
+
at `/` — Filekeeper falls back to the global store and says so in the response.
|
|
155
|
+
`keeper init` writes the setting only as a commented example, so that fallback
|
|
156
|
+
stays available; uncomment the key to pin the choice, and a project-local choice
|
|
157
|
+
then fails loudly instead of being redirected.
|
|
158
|
+
|
|
153
159
|
If the workspace is a git repository, Filekeeper adds `.filekeeper/` to
|
|
154
160
|
`.gitignore` when it creates the state directory. Outside a repository it writes
|
|
155
161
|
nothing.
|
|
@@ -129,6 +129,12 @@ home directory instead. The trade-off: history survives deleting the checkout, b
|
|
|
129
129
|
per-project attribution and pruning become harder, and undo history no longer
|
|
130
130
|
travels with the tree.
|
|
131
131
|
|
|
132
|
+
If the workspace root cannot host `.filekeeper/` — a read-only checkout, or a root
|
|
133
|
+
at `/` — Filekeeper falls back to the global store and says so in the response.
|
|
134
|
+
`keeper init` writes the setting only as a commented example, so that fallback
|
|
135
|
+
stays available; uncomment the key to pin the choice, and a project-local choice
|
|
136
|
+
then fails loudly instead of being redirected.
|
|
137
|
+
|
|
132
138
|
If the workspace is a git repository, Filekeeper adds `.filekeeper/` to
|
|
133
139
|
`.gitignore` when it creates the state directory. Outside a repository it writes
|
|
134
140
|
nothing.
|
|
@@ -11,7 +11,9 @@ Two kinds of state exist, and they live in different places on purpose:
|
|
|
11
11
|
|
|
12
12
|
``config.toml`` in the home directory holds tunable limits. It is read on every
|
|
13
13
|
invocation; writing it is left to the user (``keeper init`` creates it with the
|
|
14
|
-
defaults spelled out).
|
|
14
|
+
defaults spelled out). ``state_location`` is written only as a commented example:
|
|
15
|
+
a generated file must not turn a default into an explicit operator decision,
|
|
16
|
+
because an explicit decision refuses the global fallback.
|
|
15
17
|
"""
|
|
16
18
|
|
|
17
19
|
from __future__ import annotations
|
|
@@ -133,7 +135,15 @@ max_history_blob_bytes = 4194304
|
|
|
133
135
|
# parent directory before reporting success.
|
|
134
136
|
fsync = true
|
|
135
137
|
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
|
|
138
|
+
# Per-workspace state lives in <workspace>/.filekeeper/ by default. When the
|
|
139
|
+
# workspace root cannot host that directory (a read-only checkout, or a root at
|
|
140
|
+
# /), Filekeeper falls back to the global store under this home directory and
|
|
141
|
+
# says so in the response.
|
|
142
|
+
#
|
|
143
|
+
# Uncommenting the next line pins the choice instead of leaving it at the
|
|
144
|
+
# built-in default. "project" then fails loudly when the root cannot host the
|
|
145
|
+
# directory instead of being redirected; "global" always uses the shared store.
|
|
146
|
+
# An uncommented key is an operator decision, so keep it commented unless you
|
|
147
|
+
# mean it.
|
|
148
|
+
# state_location = "project"
|
|
139
149
|
"""
|
|
@@ -135,15 +135,20 @@ def uuid_token() -> str:
|
|
|
135
135
|
return uuid.uuid4().hex[:16]
|
|
136
136
|
|
|
137
137
|
|
|
138
|
-
def connect(path: Path) -> sqlite3.Connection:
|
|
139
|
-
"""Open a SQLite database with durable, foreign-key-enforcing settings.
|
|
138
|
+
def connect(path: Path, *, display: str = "the Filekeeper state store") -> sqlite3.Connection:
|
|
139
|
+
"""Open a SQLite database with durable, foreign-key-enforcing settings.
|
|
140
|
+
|
|
141
|
+
``display`` is the caller-facing name of the directory holding the database.
|
|
142
|
+
A public error envelope never carries the resolved host path; the reason
|
|
143
|
+
phrase is all the message needs.
|
|
144
|
+
"""
|
|
140
145
|
|
|
141
146
|
try:
|
|
142
147
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
148
|
except OSError as exc:
|
|
144
149
|
raise StorageError(
|
|
145
150
|
f"cannot create state directory: {exc.strerror or type(exc).__name__}",
|
|
146
|
-
details={"state_dir":
|
|
151
|
+
details={"state_dir": display},
|
|
147
152
|
) from exc
|
|
148
153
|
try:
|
|
149
154
|
os.chmod(path.parent, stat.S_IRWXU)
|
|
@@ -152,7 +157,7 @@ def connect(path: Path) -> sqlite3.Connection:
|
|
|
152
157
|
try:
|
|
153
158
|
connection = sqlite3.connect(path, timeout=10.0)
|
|
154
159
|
except sqlite3.Error as exc:
|
|
155
|
-
raise StorageError(f"cannot open database: {exc}", details={"path":
|
|
160
|
+
raise StorageError(f"cannot open database: {exc}", details={"path": display}) from exc
|
|
156
161
|
connection.row_factory = sqlite3.Row
|
|
157
162
|
connection.execute("PRAGMA journal_mode = WAL")
|
|
158
163
|
# Full synchronous mode: a committed record survives an OS crash, which the
|
|
@@ -216,14 +221,18 @@ def migrate_workspace(connection: sqlite3.Connection) -> int:
|
|
|
216
221
|
return migrate(connection, _WORKSPACE_MIGRATIONS, label="workspace")
|
|
217
222
|
|
|
218
223
|
|
|
219
|
-
def open_registry_database(
|
|
220
|
-
|
|
224
|
+
def open_registry_database(
|
|
225
|
+
path: Path, *, display: str = "the Filekeeper home directory"
|
|
226
|
+
) -> sqlite3.Connection:
|
|
227
|
+
connection = connect(path, display=display)
|
|
221
228
|
migrate_registry(connection)
|
|
222
229
|
return connection
|
|
223
230
|
|
|
224
231
|
|
|
225
|
-
def open_workspace_database(
|
|
226
|
-
|
|
232
|
+
def open_workspace_database(
|
|
233
|
+
path: Path, *, display: str = "the workspace state directory"
|
|
234
|
+
) -> sqlite3.Connection:
|
|
235
|
+
connection = connect(path, display=display)
|
|
227
236
|
migrate_workspace(connection)
|
|
228
237
|
try:
|
|
229
238
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
@@ -219,21 +219,24 @@ class FilekeeperService:
|
|
|
219
219
|
if self.registered and self.handle.workspace_id is not None:
|
|
220
220
|
workspace = self.owner.registry.get(self.handle.name)
|
|
221
221
|
return self.owner.registry.ensure_state_dir(workspace)
|
|
222
|
-
return ensure_state_directory(
|
|
222
|
+
return ensure_state_directory(
|
|
223
|
+
self.handle.state_dir, root=self.root, home=self.owner.home
|
|
224
|
+
)
|
|
223
225
|
|
|
224
226
|
def blob_store(self) -> BlobStore:
|
|
225
227
|
return BlobStore(self.state_database_path().parent / "blobs", fsync=self.config.fsync)
|
|
226
228
|
|
|
227
229
|
def _journal_for(self, dry_run: bool) -> OperationJournal | None:
|
|
228
|
-
"""The journal a mutation will record into, or a
|
|
230
|
+
"""The journal a mutation will record into, or ``None`` for a dry run.
|
|
229
231
|
|
|
230
|
-
A dry run
|
|
231
|
-
|
|
232
|
-
operation
|
|
232
|
+
A dry run has nothing to journal, so it touches no state at all: it does
|
|
233
|
+
not create the state directory, open the database or write a blob. That
|
|
234
|
+
makes a preview readable even where the real operation would be refused —
|
|
235
|
+
which is the point of a preview, and why a preview that failed on state
|
|
236
|
+
creation was not a preview.
|
|
233
237
|
"""
|
|
234
238
|
|
|
235
239
|
if dry_run:
|
|
236
|
-
self.state_database_path()
|
|
237
240
|
return None
|
|
238
241
|
return self.history
|
|
239
242
|
|
|
@@ -69,6 +69,30 @@ def global_workspace_state_dir(workspace_id: str, home: Path) -> Path:
|
|
|
69
69
|
return Path(home) / "workspaces" / workspace_id
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def display_state_location(
|
|
73
|
+
state_dir: Path, *, root: Path | None, home: Path | None
|
|
74
|
+
) -> str:
|
|
75
|
+
"""Render a state directory without naming an absolute host path.
|
|
76
|
+
|
|
77
|
+
A public error envelope is read by a caller confined to the workspace, so a
|
|
78
|
+
resolved host path there tells them nothing they can use and describes the
|
|
79
|
+
host tree. The workspace-relative form is preferred; a global store outside
|
|
80
|
+
the workspace is rendered relative to the home directory; anything else falls
|
|
81
|
+
back to prose. The value stays meaningful, which is what the operator needs
|
|
82
|
+
in order to fix the deployment.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
for base in (root, home):
|
|
86
|
+
if base is None:
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
relative = Path(state_dir).relative_to(Path(base)).as_posix()
|
|
90
|
+
except ValueError:
|
|
91
|
+
continue
|
|
92
|
+
return relative or "."
|
|
93
|
+
return "the global Filekeeper state store"
|
|
94
|
+
|
|
95
|
+
|
|
72
96
|
def _directory_usable(directory: Path) -> bool:
|
|
73
97
|
"""Whether ``directory`` exists and is writable, or can be created."""
|
|
74
98
|
|
|
@@ -157,27 +181,31 @@ def handle_for(workspace: Workspace, config: Config, home: Path) -> WorkspaceHan
|
|
|
157
181
|
)
|
|
158
182
|
|
|
159
183
|
|
|
160
|
-
def ensure_state_directory(
|
|
184
|
+
def ensure_state_directory(
|
|
185
|
+
state_dir: Path, *, root: Path | None = None, home: Path | None = None
|
|
186
|
+
) -> Path:
|
|
161
187
|
"""Create the state directory and its migrated database; return the DB path.
|
|
162
188
|
|
|
163
189
|
Every filesystem failure here is a :class:`StorageError` naming the state
|
|
164
|
-
directory the operator chose. It is never an ``INTERNAL_ERROR``, and
|
|
165
|
-
message
|
|
190
|
+
directory the operator chose. It is never an ``INTERNAL_ERROR``, and neither
|
|
191
|
+
the message nor the details carry a resolved host path: the location is
|
|
192
|
+
rendered workspace-relative (or home-relative for the global store).
|
|
166
193
|
"""
|
|
167
194
|
|
|
195
|
+
display = display_state_location(state_dir, root=root, home=home)
|
|
168
196
|
try:
|
|
169
197
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
170
198
|
state_dir.chmod(stat.S_IRWXU)
|
|
171
199
|
except OSError as exc:
|
|
172
200
|
raise StorageError(
|
|
173
201
|
f"cannot create state directory: {exc.strerror or type(exc).__name__}",
|
|
174
|
-
details={"state_dir":
|
|
202
|
+
details={"state_dir": display},
|
|
175
203
|
) from exc
|
|
176
204
|
if root is not None and state_dir.parent == root:
|
|
177
205
|
# Only a project-local directory belongs in the tree's ignore rules.
|
|
178
206
|
ensure_gitignore_entry(root)
|
|
179
207
|
database_path = state_dir / STATE_DATABASE_NAME
|
|
180
|
-
connection = database.open_workspace_database(database_path)
|
|
208
|
+
connection = database.open_workspace_database(database_path, display=display)
|
|
181
209
|
connection.close()
|
|
182
210
|
return database_path
|
|
183
211
|
|
|
@@ -228,7 +256,7 @@ class WorkspaceRegistry:
|
|
|
228
256
|
raise StorageError(
|
|
229
257
|
f"cannot create the Filekeeper home directory: "
|
|
230
258
|
f"{exc.strerror or type(exc).__name__}",
|
|
231
|
-
details={"state_dir":
|
|
259
|
+
details={"state_dir": "the Filekeeper home directory"},
|
|
232
260
|
) from exc
|
|
233
261
|
try:
|
|
234
262
|
self.home.chmod(stat.S_IRWXU)
|
|
@@ -399,7 +427,9 @@ class WorkspaceRegistry:
|
|
|
399
427
|
"""
|
|
400
428
|
|
|
401
429
|
return ensure_state_directory(
|
|
402
|
-
self.state_dir_for(workspace),
|
|
430
|
+
self.state_dir_for(workspace),
|
|
431
|
+
root=Path(workspace.root_path),
|
|
432
|
+
home=self.home,
|
|
403
433
|
)
|
|
404
434
|
|
|
405
435
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|