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.
Files changed (36) hide show
  1. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/PKG-INFO +7 -1
  2. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/README.md +6 -0
  3. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/pyproject.toml +1 -1
  4. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/pyproject.toml.orig +1 -1
  5. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/__init__.py +1 -1
  6. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/config.py +14 -4
  7. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/database.py +17 -8
  8. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/service.py +9 -6
  9. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/workspace.py +37 -7
  10. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/LICENSE +0 -0
  11. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/cli/__init__.py +0 -0
  12. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/cli/app.py +0 -0
  13. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/cli/exit_codes.py +0 -0
  14. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/cli/output.py +0 -0
  15. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/__init__.py +0 -0
  16. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/diffing.py +0 -0
  17. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/errors.py +0 -0
  18. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/__init__.py +0 -0
  19. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/blobs.py +0 -0
  20. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/journal.py +0 -0
  21. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/repository.py +0 -0
  22. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/undo.py +0 -0
  23. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/models.py +0 -0
  24. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/__init__.py +0 -0
  25. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/directory.py +0 -0
  26. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/edit.py +0 -0
  27. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/read.py +0 -0
  28. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/search.py +0 -0
  29. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/write.py +0 -0
  30. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/paths.py +0 -0
  31. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/__init__.py +0 -0
  32. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/atomic.py +0 -0
  33. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/hashing.py +0 -0
  34. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/sensitive.py +0 -0
  35. {filekeeper_cli-0.1.3 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/textdata.py +0 -0
  36. {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
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "filekeeper-cli"
3
- version = "0.1.3"
3
+ version = "0.1.4"
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.4"
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"
@@ -21,7 +21,7 @@ from .core import (
21
21
  Workspace,
22
22
  )
23
23
 
24
- __version__ = "0.1.3"
24
+ __version__ = "0.1.4"
25
25
 
26
26
  __all__ = [
27
27
  "Config",
@@ -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)
@@ -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(self.handle.state_dir, root=self.root)
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 verified ``None``.
230
+ """The journal a mutation will record into, or ``None`` for a dry run.
229
231
 
230
- A dry run writes no history, but it still proves that the journal can be
231
- opened: a preview must never report success for a call the real
232
- operation could not perform.
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(state_dir: Path, *, root: Path | None = None) -> Path:
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 the
165
- message carries only the reason phrase, never a resolved host path.
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": str(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": str(self.home)},
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), root=Path(workspace.root_path)
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