filekeeper-cli 0.1.3__tar.gz → 0.1.6__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.6}/PKG-INFO +49 -7
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/README.md +48 -6
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/pyproject.toml +1 -1
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/pyproject.toml.orig +1 -1
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/__init__.py +5 -1
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/cli/output.py +13 -1
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/__init__.py +3 -1
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/config.py +14 -4
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/database.py +17 -8
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/errors.py +65 -2
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/journal.py +4 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/undo.py +90 -6
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/models.py +77 -3
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/directory.py +42 -9
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/edit.py +47 -19
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/write.py +87 -14
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/service.py +24 -9
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/workspace.py +37 -7
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/LICENSE +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/cli/__init__.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/cli/app.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/cli/exit_codes.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/diffing.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/__init__.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/blobs.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/repository.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/__init__.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/read.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/search.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/paths.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/__init__.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/atomic.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/hashing.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/sensitive.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/textdata.py +0 -0
- {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/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.6
|
|
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.
|
|
@@ -238,6 +244,13 @@ later file fails after earlier ones were written, the written files are rolled
|
|
|
238
244
|
back to their pre-patch bytes and the failure names what was restored. A crash is
|
|
239
245
|
resolved from the pending journal record, never from an all-or-nothing commit.
|
|
240
246
|
|
|
247
|
+
When a hunk's header and its body disagree — the hunk is cut short or a line
|
|
248
|
+
prefix is not ` `, `+` or `-` — the `PATCH_FAILED` error states the counts the
|
|
249
|
+
header declares and the counts the body actually supplied, in the message and in
|
|
250
|
+
`details.expected_old_lines`, `expected_new_lines`, `actual_old_lines` and
|
|
251
|
+
`actual_new_lines`. The existing `line` and `reason` fields are still there. Patch
|
|
252
|
+
text is never echoed into the error.
|
|
253
|
+
|
|
241
254
|
## Commands
|
|
242
255
|
|
|
243
256
|
```
|
|
@@ -298,10 +311,11 @@ affected path with the state the operation recorded:
|
|
|
298
311
|
|
|
299
312
|
- if the path still matches the recorded **after** state, undo restores the
|
|
300
313
|
recorded **before** bytes;
|
|
301
|
-
- if it already matches the **before** state
|
|
302
|
-
operation), undo leaves it alone;
|
|
303
|
-
- if
|
|
304
|
-
`UNDO_CONFLICT` (exit code
|
|
314
|
+
- if it already matches the **before** state of an operation that never committed
|
|
315
|
+
(a partially applied multi-file operation), undo leaves it alone;
|
|
316
|
+
- if a committed operation's path no longer matches the **after** state — edited,
|
|
317
|
+
deleted, or renamed away — the undo is refused with `UNDO_CONFLICT` (exit code
|
|
318
|
+
12) and **nothing is modified**.
|
|
305
319
|
|
|
306
320
|
Undoing a create removes the file only while it still matches what was created.
|
|
307
321
|
Undoing a delete restores the file only while the destination is still absent.
|
|
@@ -312,6 +326,15 @@ The undo is itself recorded as an operation, so `history list` remains a
|
|
|
312
326
|
truthful ledger. **Redo is deferred** — it is not half-implemented; undoing an
|
|
313
327
|
undo is the supported way to go forward again.
|
|
314
328
|
|
|
329
|
+
A copy or a move records the path it read from in the operation's `metadata`
|
|
330
|
+
under `source_path`, and its summary names both ends. `target_path` keeps its
|
|
331
|
+
meaning — the path the operation targets — so existing readers are unaffected.
|
|
332
|
+
|
|
333
|
+
A refused undo is recorded too: the ledger gains an `undo_refused` row with the
|
|
334
|
+
target path, the conflict code, and the paths that blocked the undo. It changed
|
|
335
|
+
nothing, so it carries no items, is never offered as undoable, and `undo-last`
|
|
336
|
+
skips it. Every recorded path is workspace-relative, never a resolved host path.
|
|
337
|
+
|
|
315
338
|
`keeper file read` writes file content to standard output unchanged (line endings
|
|
316
339
|
included). Metadata goes to stderr, or into the JSON object with `--json`.
|
|
317
340
|
`--input FILE` reads replacement content from a file, avoiding shell argument
|
|
@@ -374,6 +397,16 @@ re-reads the file and writes against its new hash.
|
|
|
374
397
|
so its previous bytes were deliberately not stored; `history_skipped` then names
|
|
375
398
|
the paths.
|
|
376
399
|
|
|
400
|
+
**A dry run reports a preview, not a write.** With `"dry_run": true`, `changed` is
|
|
401
|
+
`false`, `operation_id` and `after_hash` are `null`, and each item's `after_hash`
|
|
402
|
+
and `size_bytes` are `null`: nothing was observed, so nothing is claimed. The hash
|
|
403
|
+
a real run would produce appears as `predicted_after_hash` — a prediction, never an
|
|
404
|
+
observation — and `would_change` says whether a real run would alter anything.
|
|
405
|
+
`before_hash` and `preview_diff` are kept, because they describe the state the
|
|
406
|
+
preview was computed against. The same shape applies to every mutating command and
|
|
407
|
+
to the MCP tools that mirror them. A committed operation still reports
|
|
408
|
+
`"changed": true` with its observed `after_hash`.
|
|
409
|
+
|
|
377
410
|
Editing results add `match_stage` and `match_count` (text replacement),
|
|
378
411
|
`preview_diff` (dry run), and, for patches, `patch_files` with per-hunk status.
|
|
379
412
|
A failure that was rolled back reports the restored paths in the error details.
|
|
@@ -415,6 +448,13 @@ a shell predicate.
|
|
|
415
448
|
|
|
416
449
|
Codes are stable; messages are not a contract.
|
|
417
450
|
|
|
451
|
+
Messages name the interface the caller is using. A refusal that offers an
|
|
452
|
+
override states that interface's own argument: Core's keyword, the CLI's flag, or
|
|
453
|
+
the MCP tool argument. The same request therefore reads correctly whether it came
|
|
454
|
+
from a library, a shell, or an agent over MCP, and an adapter never prints another
|
|
455
|
+
surface's argument. Codes, `retryable` and `details` are identical across all
|
|
456
|
+
three surfaces.
|
|
457
|
+
|
|
418
458
|
## Safety rules
|
|
419
459
|
|
|
420
460
|
- **Confinement.** Relative paths resolve inside the workspace root. Absolute paths
|
|
@@ -429,8 +469,10 @@ Codes are stable; messages are not a contract.
|
|
|
429
469
|
`*.key`, `id_rsa` and its siblings, `*.p12`, `*.pfx`, `authorized_keys`,
|
|
430
470
|
`.netrc`, and anything under `.ssh/`, `.gnupg/` or `.aws/` fail with
|
|
431
471
|
`SENSITIVE_PATH_REFUSED` (exit code 10). Override deliberately with
|
|
432
|
-
`--i-accept-secret-write-risk`
|
|
433
|
-
|
|
472
|
+
`--i-accept-secret-write-risk` on the CLI, `allow_sensitive_write=True` in Core,
|
|
473
|
+
or `i_accept_secret_write_risk` on the MCP tool. Each name is long because the
|
|
474
|
+
mistake is expensive. The error message names only the argument belonging to the
|
|
475
|
+
interface that received the request.
|
|
434
476
|
- **Version-control-ignored targets warn.** Writing a path that git ignores still
|
|
435
477
|
succeeds, but the result carries a warning that the change will not be committed.
|
|
436
478
|
- **No shell.** Filekeeper never interpolates paths into a shell command.
|
|
@@ -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.
|
|
@@ -217,6 +223,13 @@ later file fails after earlier ones were written, the written files are rolled
|
|
|
217
223
|
back to their pre-patch bytes and the failure names what was restored. A crash is
|
|
218
224
|
resolved from the pending journal record, never from an all-or-nothing commit.
|
|
219
225
|
|
|
226
|
+
When a hunk's header and its body disagree — the hunk is cut short or a line
|
|
227
|
+
prefix is not ` `, `+` or `-` — the `PATCH_FAILED` error states the counts the
|
|
228
|
+
header declares and the counts the body actually supplied, in the message and in
|
|
229
|
+
`details.expected_old_lines`, `expected_new_lines`, `actual_old_lines` and
|
|
230
|
+
`actual_new_lines`. The existing `line` and `reason` fields are still there. Patch
|
|
231
|
+
text is never echoed into the error.
|
|
232
|
+
|
|
220
233
|
## Commands
|
|
221
234
|
|
|
222
235
|
```
|
|
@@ -277,10 +290,11 @@ affected path with the state the operation recorded:
|
|
|
277
290
|
|
|
278
291
|
- if the path still matches the recorded **after** state, undo restores the
|
|
279
292
|
recorded **before** bytes;
|
|
280
|
-
- if it already matches the **before** state
|
|
281
|
-
operation), undo leaves it alone;
|
|
282
|
-
- if
|
|
283
|
-
`UNDO_CONFLICT` (exit code
|
|
293
|
+
- if it already matches the **before** state of an operation that never committed
|
|
294
|
+
(a partially applied multi-file operation), undo leaves it alone;
|
|
295
|
+
- if a committed operation's path no longer matches the **after** state — edited,
|
|
296
|
+
deleted, or renamed away — the undo is refused with `UNDO_CONFLICT` (exit code
|
|
297
|
+
12) and **nothing is modified**.
|
|
284
298
|
|
|
285
299
|
Undoing a create removes the file only while it still matches what was created.
|
|
286
300
|
Undoing a delete restores the file only while the destination is still absent.
|
|
@@ -291,6 +305,15 @@ The undo is itself recorded as an operation, so `history list` remains a
|
|
|
291
305
|
truthful ledger. **Redo is deferred** — it is not half-implemented; undoing an
|
|
292
306
|
undo is the supported way to go forward again.
|
|
293
307
|
|
|
308
|
+
A copy or a move records the path it read from in the operation's `metadata`
|
|
309
|
+
under `source_path`, and its summary names both ends. `target_path` keeps its
|
|
310
|
+
meaning — the path the operation targets — so existing readers are unaffected.
|
|
311
|
+
|
|
312
|
+
A refused undo is recorded too: the ledger gains an `undo_refused` row with the
|
|
313
|
+
target path, the conflict code, and the paths that blocked the undo. It changed
|
|
314
|
+
nothing, so it carries no items, is never offered as undoable, and `undo-last`
|
|
315
|
+
skips it. Every recorded path is workspace-relative, never a resolved host path.
|
|
316
|
+
|
|
294
317
|
`keeper file read` writes file content to standard output unchanged (line endings
|
|
295
318
|
included). Metadata goes to stderr, or into the JSON object with `--json`.
|
|
296
319
|
`--input FILE` reads replacement content from a file, avoiding shell argument
|
|
@@ -353,6 +376,16 @@ re-reads the file and writes against its new hash.
|
|
|
353
376
|
so its previous bytes were deliberately not stored; `history_skipped` then names
|
|
354
377
|
the paths.
|
|
355
378
|
|
|
379
|
+
**A dry run reports a preview, not a write.** With `"dry_run": true`, `changed` is
|
|
380
|
+
`false`, `operation_id` and `after_hash` are `null`, and each item's `after_hash`
|
|
381
|
+
and `size_bytes` are `null`: nothing was observed, so nothing is claimed. The hash
|
|
382
|
+
a real run would produce appears as `predicted_after_hash` — a prediction, never an
|
|
383
|
+
observation — and `would_change` says whether a real run would alter anything.
|
|
384
|
+
`before_hash` and `preview_diff` are kept, because they describe the state the
|
|
385
|
+
preview was computed against. The same shape applies to every mutating command and
|
|
386
|
+
to the MCP tools that mirror them. A committed operation still reports
|
|
387
|
+
`"changed": true` with its observed `after_hash`.
|
|
388
|
+
|
|
356
389
|
Editing results add `match_stage` and `match_count` (text replacement),
|
|
357
390
|
`preview_diff` (dry run), and, for patches, `patch_files` with per-hunk status.
|
|
358
391
|
A failure that was rolled back reports the restored paths in the error details.
|
|
@@ -394,6 +427,13 @@ a shell predicate.
|
|
|
394
427
|
|
|
395
428
|
Codes are stable; messages are not a contract.
|
|
396
429
|
|
|
430
|
+
Messages name the interface the caller is using. A refusal that offers an
|
|
431
|
+
override states that interface's own argument: Core's keyword, the CLI's flag, or
|
|
432
|
+
the MCP tool argument. The same request therefore reads correctly whether it came
|
|
433
|
+
from a library, a shell, or an agent over MCP, and an adapter never prints another
|
|
434
|
+
surface's argument. Codes, `retryable` and `details` are identical across all
|
|
435
|
+
three surfaces.
|
|
436
|
+
|
|
397
437
|
## Safety rules
|
|
398
438
|
|
|
399
439
|
- **Confinement.** Relative paths resolve inside the workspace root. Absolute paths
|
|
@@ -408,8 +448,10 @@ Codes are stable; messages are not a contract.
|
|
|
408
448
|
`*.key`, `id_rsa` and its siblings, `*.p12`, `*.pfx`, `authorized_keys`,
|
|
409
449
|
`.netrc`, and anything under `.ssh/`, `.gnupg/` or `.aws/` fail with
|
|
410
450
|
`SENSITIVE_PATH_REFUSED` (exit code 10). Override deliberately with
|
|
411
|
-
`--i-accept-secret-write-risk`
|
|
412
|
-
|
|
451
|
+
`--i-accept-secret-write-risk` on the CLI, `allow_sensitive_write=True` in Core,
|
|
452
|
+
or `i_accept_secret_write_risk` on the MCP tool. Each name is long because the
|
|
453
|
+
mistake is expensive. The error message names only the argument belonging to the
|
|
454
|
+
interface that received the request.
|
|
413
455
|
- **Version-control-ignored targets warn.** Writing a path that git ignores still
|
|
414
456
|
succeeds, but the result carries a warning that the change will not be committed.
|
|
415
457
|
- **No shell.** Filekeeper never interpolates paths into a shell command.
|
|
@@ -12,24 +12,28 @@ work; see ``CHANGELOG.md`` and ``README.md``.
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
from .core import (
|
|
15
|
+
CallerSurface,
|
|
15
16
|
Config,
|
|
16
17
|
ErrorCode,
|
|
17
18
|
Filekeeper,
|
|
18
19
|
FilekeeperError,
|
|
19
20
|
FilekeeperService,
|
|
20
21
|
MutationResult,
|
|
22
|
+
OverrideHint,
|
|
21
23
|
Workspace,
|
|
22
24
|
)
|
|
23
25
|
|
|
24
|
-
__version__ = "0.1.
|
|
26
|
+
__version__ = "0.1.6"
|
|
25
27
|
|
|
26
28
|
__all__ = [
|
|
29
|
+
"CallerSurface",
|
|
27
30
|
"Config",
|
|
28
31
|
"ErrorCode",
|
|
29
32
|
"Filekeeper",
|
|
30
33
|
"FilekeeperError",
|
|
31
34
|
"FilekeeperService",
|
|
32
35
|
"MutationResult",
|
|
36
|
+
"OverrideHint",
|
|
33
37
|
"Workspace",
|
|
34
38
|
"__version__",
|
|
35
39
|
]
|
|
@@ -14,7 +14,7 @@ from enum import Enum
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Any
|
|
16
16
|
|
|
17
|
-
from ..core.errors import FilekeeperError
|
|
17
|
+
from ..core.errors import CallerSurface, FilekeeperError
|
|
18
18
|
from ..core.models import (
|
|
19
19
|
ContentSearchResult,
|
|
20
20
|
DirectoryListing,
|
|
@@ -53,6 +53,9 @@ def print_json(payload: dict[str, Any]) -> None:
|
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
def print_error(error: FilekeeperError, *, as_json: bool) -> None:
|
|
56
|
+
"""Render an error for this surface: its override hint names this CLI's flag."""
|
|
57
|
+
|
|
58
|
+
error = error.for_surface(CallerSurface.CLI)
|
|
56
59
|
if as_json:
|
|
57
60
|
print_json({"ok": False, "error": error.to_dict()})
|
|
58
61
|
return
|
|
@@ -210,6 +213,15 @@ def history_show(record: OperationRecord) -> str:
|
|
|
210
213
|
f" {item.item_type:<9} {item.relative_path} exists {exists} "
|
|
211
214
|
f"before {item.before_blob_hash or '-'} after {item.after_blob_hash or '-'}"
|
|
212
215
|
)
|
|
216
|
+
if record.metadata:
|
|
217
|
+
lines.append("metadata:")
|
|
218
|
+
for key in sorted(record.metadata):
|
|
219
|
+
value = record.metadata[key]
|
|
220
|
+
if isinstance(value, list):
|
|
221
|
+
rendered = ", ".join(str(entry) for entry in value)
|
|
222
|
+
else:
|
|
223
|
+
rendered = str(value)
|
|
224
|
+
lines.append(f" {key}: {rendered}")
|
|
213
225
|
if record.history_skipped:
|
|
214
226
|
lines.append("history_skipped: " + ", ".join(record.history_skipped))
|
|
215
227
|
return "\n".join(lines)
|
|
@@ -5,7 +5,7 @@ the CLI, of the MCP server, or of any filesystem protocol.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from .config import Config
|
|
8
|
-
from .errors import ErrorCode, FilekeeperError
|
|
8
|
+
from .errors import CallerSurface, ErrorCode, FilekeeperError, OverrideHint
|
|
9
9
|
from .history.blobs import BlobStore
|
|
10
10
|
from .models import (
|
|
11
11
|
ContentMatch,
|
|
@@ -27,6 +27,7 @@ from .workspace import WorkspaceHandle, WorkspaceRegistry
|
|
|
27
27
|
|
|
28
28
|
__all__ = [
|
|
29
29
|
"BlobStore",
|
|
30
|
+
"CallerSurface",
|
|
30
31
|
"Config",
|
|
31
32
|
"ContentMatch",
|
|
32
33
|
"ContentSearchResult",
|
|
@@ -41,6 +42,7 @@ __all__ = [
|
|
|
41
42
|
"FilekeeperService",
|
|
42
43
|
"MutationResult",
|
|
43
44
|
"OperationItemResult",
|
|
45
|
+
"OverrideHint",
|
|
44
46
|
"PathInfo",
|
|
45
47
|
"PathSearchResult",
|
|
46
48
|
"ReadManyEntry",
|
|
@@ -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)
|
|
@@ -7,6 +7,9 @@ error payloads, and callers may branch on the code without parsing prose.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import copy
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
10
13
|
from enum import StrEnum
|
|
11
14
|
from typing import Any
|
|
12
15
|
|
|
@@ -46,6 +49,38 @@ class ErrorCode(StrEnum):
|
|
|
46
49
|
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
47
50
|
|
|
48
51
|
|
|
52
|
+
class CallerSurface(StrEnum):
|
|
53
|
+
"""The interface a caller reached Core through.
|
|
54
|
+
|
|
55
|
+
A refusal that offers an override must name the argument the caller actually
|
|
56
|
+
has. Core renders the sentence for ``CORE``; each adapter re-renders the same
|
|
57
|
+
error for its own surface before returning it, so no message ever names
|
|
58
|
+
another interface's argument.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
CORE = "core"
|
|
62
|
+
CLI = "cli"
|
|
63
|
+
MCP = "mcp"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class OverrideHint:
|
|
68
|
+
"""One "pass X to override" instruction, worded for each caller surface."""
|
|
69
|
+
|
|
70
|
+
sentences: Mapping[CallerSurface, str]
|
|
71
|
+
|
|
72
|
+
def sentence(self, surface: CallerSurface) -> str:
|
|
73
|
+
return self.sentences[surface]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def override_hint(*, core: str, cli: str, mcp: str) -> OverrideHint:
|
|
77
|
+
"""Build a hint whose sentence names each surface's own argument."""
|
|
78
|
+
|
|
79
|
+
return OverrideHint(
|
|
80
|
+
{CallerSurface.CORE: core, CallerSurface.CLI: cli, CallerSurface.MCP: mcp}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
49
84
|
class FilekeeperError(Exception):
|
|
50
85
|
"""Base error carrying a stable code, a human message, and optional details."""
|
|
51
86
|
|
|
@@ -59,6 +94,7 @@ class FilekeeperError(Exception):
|
|
|
59
94
|
code: ErrorCode | None = None,
|
|
60
95
|
retryable: bool | None = None,
|
|
61
96
|
details: dict[str, Any] | None = None,
|
|
97
|
+
override_hint: OverrideHint | None = None,
|
|
62
98
|
) -> None:
|
|
63
99
|
super().__init__(message)
|
|
64
100
|
self.message = message
|
|
@@ -67,6 +103,27 @@ class FilekeeperError(Exception):
|
|
|
67
103
|
if retryable is not None:
|
|
68
104
|
self.retryable = retryable
|
|
69
105
|
self.details: dict[str, Any] = details or {}
|
|
106
|
+
# Not serialised: the hint only lets an adapter re-word the message.
|
|
107
|
+
self.override_hint = override_hint
|
|
108
|
+
|
|
109
|
+
def for_surface(self, surface: CallerSurface) -> FilekeeperError:
|
|
110
|
+
"""Return this error worded for the interface that received it.
|
|
111
|
+
|
|
112
|
+
``code``, ``retryable`` and ``details`` are carried over untouched; only
|
|
113
|
+
the message's override sentence changes. An error with no hint, or a
|
|
114
|
+
request for the surface Core already rendered, is returned as is.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
hint = self.override_hint
|
|
118
|
+
if hint is None or surface is CallerSurface.CORE:
|
|
119
|
+
return self
|
|
120
|
+
core_sentence = hint.sentence(CallerSurface.CORE)
|
|
121
|
+
if core_sentence not in self.message:
|
|
122
|
+
return self
|
|
123
|
+
adapted = copy.copy(self)
|
|
124
|
+
adapted.message = self.message.replace(core_sentence, hint.sentence(surface))
|
|
125
|
+
adapted.args = (adapted.message,)
|
|
126
|
+
return adapted
|
|
70
127
|
|
|
71
128
|
def to_dict(self) -> dict[str, Any]:
|
|
72
129
|
payload: dict[str, Any] = {
|
|
@@ -135,8 +192,14 @@ class SensitivePathRefusedError(FilekeeperError):
|
|
|
135
192
|
|
|
136
193
|
code = ErrorCode.SENSITIVE_PATH_REFUSED
|
|
137
194
|
|
|
138
|
-
def __init__(
|
|
139
|
-
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
message: str,
|
|
198
|
+
*,
|
|
199
|
+
details: dict[str, Any] | None = None,
|
|
200
|
+
override_hint: OverrideHint | None = None,
|
|
201
|
+
) -> None:
|
|
202
|
+
super().__init__(message, details=details, override_hint=override_hint)
|
|
140
203
|
|
|
141
204
|
|
|
142
205
|
class AlreadyExistsError(FilekeeperError):
|
|
@@ -38,6 +38,9 @@ COMMITTED = "committed"
|
|
|
38
38
|
UNDONE = "undone"
|
|
39
39
|
FAILED = "failed"
|
|
40
40
|
PARTIALLY_UNDONE = "partially_undone"
|
|
41
|
+
# A refused undo is recorded so the ledger shows the attempt, but it is not a
|
|
42
|
+
# mutation and is never offered as undoable.
|
|
43
|
+
UNDO_REFUSED = "undo_refused"
|
|
41
44
|
|
|
42
45
|
|
|
43
46
|
@dataclass(frozen=True, slots=True)
|
|
@@ -424,6 +427,7 @@ __all__ = [
|
|
|
424
427
|
"PARTIALLY_UNDONE",
|
|
425
428
|
"PENDING",
|
|
426
429
|
"UNDONE",
|
|
430
|
+
"UNDO_REFUSED",
|
|
427
431
|
"BeginResult",
|
|
428
432
|
"OperationJournal",
|
|
429
433
|
"PendingItem",
|