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.
Files changed (36) hide show
  1. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/PKG-INFO +49 -7
  2. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/README.md +48 -6
  3. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/pyproject.toml +1 -1
  4. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/pyproject.toml.orig +1 -1
  5. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/__init__.py +5 -1
  6. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/cli/output.py +13 -1
  7. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/__init__.py +3 -1
  8. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/config.py +14 -4
  9. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/database.py +17 -8
  10. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/errors.py +65 -2
  11. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/journal.py +4 -0
  12. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/undo.py +90 -6
  13. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/models.py +77 -3
  14. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/directory.py +42 -9
  15. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/edit.py +47 -19
  16. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/write.py +87 -14
  17. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/service.py +24 -9
  18. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/workspace.py +37 -7
  19. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/LICENSE +0 -0
  20. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/cli/__init__.py +0 -0
  21. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/cli/app.py +0 -0
  22. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/cli/exit_codes.py +0 -0
  23. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/diffing.py +0 -0
  24. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/__init__.py +0 -0
  25. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/blobs.py +0 -0
  26. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/repository.py +0 -0
  27. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/__init__.py +0 -0
  28. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/read.py +0 -0
  29. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/search.py +0 -0
  30. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/paths.py +0 -0
  31. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/__init__.py +0 -0
  32. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/atomic.py +0 -0
  33. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/hashing.py +0 -0
  34. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/sensitive.py +0 -0
  35. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/textdata.py +0 -0
  36. {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
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 (a partially applied multi-file
302
- operation), undo leaves it alone;
303
- - if it matches neither, someone changed it afterwards: the undo is refused with
304
- `UNDO_CONFLICT` (exit code 12) and **nothing is modified**.
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` (Core: `allow_sensitive_write=True`). The flag is
433
- long because the mistake is expensive.
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 (a partially applied multi-file
281
- operation), undo leaves it alone;
282
- - if it matches neither, someone changed it afterwards: the undo is refused with
283
- `UNDO_CONFLICT` (exit code 12) and **nothing is modified**.
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` (Core: `allow_sensitive_write=True`). The flag is
412
- long because the mistake is expensive.
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "filekeeper-cli"
3
- version = "0.1.3"
3
+ version = "0.1.6"
4
4
  description = "Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "filekeeper-cli"
3
- version = "0.1.3"
3
+ version = "0.1.6"
4
4
  description = "Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -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.3"
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
- # "project" keeps per-workspace state in <workspace>/.filekeeper/ (default).
137
- # "global" keeps one shared store under this home directory instead.
138
- state_location = "project"
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": str(path.parent)},
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": str(path)}) from exc
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(path: Path) -> sqlite3.Connection:
220
- connection = connect(path)
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(path: Path) -> sqlite3.Connection:
226
- connection = connect(path)
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__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
139
- super().__init__(message, details=details)
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",