pocketshell 0.4.46__tar.gz → 0.5.0__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 (93) hide show
  1. {pocketshell-0.4.46 → pocketshell-0.5.0}/PKG-INFO +1 -1
  2. {pocketshell-0.4.46 → pocketshell-0.5.0}/pyproject.toml +5 -1
  3. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/agent_log.py +14 -8
  4. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/cards.py +142 -49
  5. pocketshell-0.5.0/src/pocketshell/config.py +163 -0
  6. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/daemon.py +82 -43
  7. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/engines.py +333 -54
  8. pocketshell-0.5.0/src/pocketshell/session_enum.py +686 -0
  9. pocketshell-0.5.0/src/pocketshell/sessions.py +1296 -0
  10. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/tree.py +99 -65
  11. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/usage_reset.py +11 -7
  12. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/conftest.py +11 -1
  13. pocketshell-0.5.0/tests/fixtures/aplexer/snapshot-reported-state.json +226 -0
  14. pocketshell-0.5.0/tests/fixtures/aplexer/snapshot.json +402 -0
  15. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_agents.py +48 -0
  16. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_cards.py +199 -2
  17. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_daemon.py +76 -1
  18. pocketshell-0.5.0/tests/test_engines.py +759 -0
  19. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_send.py +3 -0
  20. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_session_enum.py +6 -4
  21. pocketshell-0.5.0/tests/test_session_enum_schema2.py +664 -0
  22. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_sessions.py +8 -1
  23. pocketshell-0.5.0/tests/test_sessions_attach.py +480 -0
  24. pocketshell-0.5.0/tests/test_sessions_create_routing.py +793 -0
  25. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_tree.py +151 -23
  26. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_usage.py +2 -0
  27. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_usage_reset.py +25 -1
  28. pocketshell-0.4.46/src/pocketshell/session_enum.py +0 -267
  29. pocketshell-0.4.46/src/pocketshell/sessions.py +0 -497
  30. pocketshell-0.4.46/tests/test_engines.py +0 -263
  31. {pocketshell-0.4.46 → pocketshell-0.5.0}/.gitignore +0 -0
  32. {pocketshell-0.4.46 → pocketshell-0.5.0}/README.md +0 -0
  33. {pocketshell-0.4.46 → pocketshell-0.5.0}/scheduler/README.md +0 -0
  34. {pocketshell-0.4.46 → pocketshell-0.5.0}/scheduler/pocketshell-usage-capture.service +0 -0
  35. {pocketshell-0.4.46 → pocketshell-0.5.0}/scheduler/pocketshell-usage-capture.timer +0 -0
  36. {pocketshell-0.4.46 → pocketshell-0.5.0}/scripts/sync-readme-exit-codes.py +0 -0
  37. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/__init__.py +0 -0
  38. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/__main__.py +0 -0
  39. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/agent_card_push.py +0 -0
  40. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/agents.py +0 -0
  41. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/agents_kind.py +0 -0
  42. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/aplexer.py +0 -0
  43. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/cgroup_agents.py +0 -0
  44. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/cli.py +0 -0
  45. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/env.py +0 -0
  46. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/github.py +0 -0
  47. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/hooks.py +0 -0
  48. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/jobs.py +0 -0
  49. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/logs.py +0 -0
  50. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/profiles.py +0 -0
  51. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/prune_attachments.py +0 -0
  52. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/push.py +0 -0
  53. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/qr_share.py +0 -0
  54. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/repos.py +0 -0
  55. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/resume.py +0 -0
  56. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/send.py +0 -0
  57. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/serve.py +0 -0
  58. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/usage.py +0 -0
  59. {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/usage_capture.py +0 -0
  60. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/__init__.py +0 -0
  61. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/data/quse-0.0.11-usage.json +0 -0
  62. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/data/quse-0.0.13-usage.json +0 -0
  63. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/data/quse-0.0.15-usage.json +0 -0
  64. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/data/quse-0.0.15-usage.ndjson +0 -0
  65. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/data/quse-0.0.15-usage.provenance.txt +0 -0
  66. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/data/quse-0.0.9-usage.json +0 -0
  67. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/data/sshd_anchor/Dockerfile.from-unanchored-base +0 -0
  68. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_agent_card_push.py +0 -0
  69. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_agent_log.py +0 -0
  70. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_agents_kind.py +0 -0
  71. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_agents_kind_target.py +0 -0
  72. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_agents_source_target.py +0 -0
  73. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_cards_push_notify.py +0 -0
  74. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_cgroup_agents.py +0 -0
  75. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_cli.py +0 -0
  76. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_daemon_fallback.py +0 -0
  77. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_env.py +0 -0
  78. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_github.py +0 -0
  79. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_hooks.py +0 -0
  80. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_jobs.py +0 -0
  81. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_logs.py +0 -0
  82. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_profiles.py +0 -0
  83. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_prune_attachments.py +0 -0
  84. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_push.py +0 -0
  85. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_qr_share.py +0 -0
  86. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_repos.py +0 -0
  87. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_resume.py +0 -0
  88. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_serve.py +0 -0
  89. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_ssh_fixture_penalties.py +0 -0
  90. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_usage_capture.py +0 -0
  91. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_watch_ci.py +0 -0
  92. {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_watch_ci_cancelled.py +0 -0
  93. {pocketshell-0.4.46 → pocketshell-0.5.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pocketshell
3
- Version: 0.4.46
3
+ Version: 0.5.0
4
4
  Summary: Unified server-side Python utility for the PocketShell Android client.
5
5
  Project-URL: Homepage, https://github.com/alexeygrigorev/pocketshell
6
6
  Project-URL: Issues, https://github.com/alexeygrigorev/pocketshell/issues
@@ -14,7 +14,7 @@ name = "pocketshell"
14
14
  # scripts/check-pypi-version.sh enforces the stamped value matches the tag;
15
15
  # scripts/check-version-coupling.sh enforces the derivation itself is
16
16
  # single-sourced. See tools/pocketshell/README.md ("Version coupling").
17
- version = "0.4.46"
17
+ version = "0.5.0"
18
18
  description = "Unified server-side Python utility for the PocketShell Android client."
19
19
  readme = "README.md"
20
20
  requires-python = ">=3.11"
@@ -129,6 +129,10 @@ exclude = [
129
129
  ]
130
130
 
131
131
  [tool.uv]
132
+ # CI and local frozen-sync checks use the same installer semantics. Bump this
133
+ # deliberately together with .github/workflows/tests.yml and validate the
134
+ # committed lock before merging.
135
+ required-version = "==0.10.11"
132
136
  # Keep the lockfile's reproducibility cutoff project-local. The host may have
133
137
  # a rolling global `exclude-newer` policy that predates deliberately pinned
134
138
  # releases (quse 0.0.15 and tmuxctl 0.3.5); this exact value is also the
@@ -539,6 +539,19 @@ def _grok_messages_from_row(row: dict[str, Any]) -> List[HandoffMessage]:
539
539
  return []
540
540
 
541
541
 
542
+ def _handoff_messages_from_row(engine: str, row: dict[str, Any]) -> List[HandoffMessage]:
543
+ """Dispatch one parsed JSONL row to its per-engine message extractor."""
544
+ if engine == "claude":
545
+ return _claude_messages_from_row(row)
546
+ if engine == "codex":
547
+ return _codex_messages_from_row(row)
548
+ if engine == "opencode":
549
+ return _opencode_messages_from_row(row)
550
+ if engine == "grok":
551
+ return _grok_messages_from_row(row)
552
+ return []
553
+
554
+
542
555
  def _handoff_messages_from_lines(engine: str, lines: Iterable[str]) -> List[HandoffMessage]:
543
556
  """Extract user/assistant prose from raw agent JSONL rows."""
544
557
  messages: List[HandoffMessage] = []
@@ -546,14 +559,7 @@ def _handoff_messages_from_lines(engine: str, lines: Iterable[str]) -> List[Hand
546
559
  row = _parse_json_line(line)
547
560
  if row is None:
548
561
  continue
549
- if engine == "claude":
550
- messages.extend(_claude_messages_from_row(row))
551
- elif engine == "codex":
552
- messages.extend(_codex_messages_from_row(row))
553
- elif engine == "opencode":
554
- messages.extend(_opencode_messages_from_row(row))
555
- elif engine == "grok":
556
- messages.extend(_grok_messages_from_row(row))
562
+ messages.extend(_handoff_messages_from_row(engine, row))
557
563
  return messages
558
564
 
559
565
 
@@ -58,6 +58,12 @@ from __future__ import annotations
58
58
  import os
59
59
  import re
60
60
  import sys
61
+ import base64
62
+ import errno
63
+ import fcntl
64
+ import logging
65
+ import tempfile
66
+ from contextlib import contextmanager
61
67
  from dataclasses import dataclass
62
68
  from datetime import datetime, timezone
63
69
  from pathlib import Path
@@ -67,6 +73,10 @@ import click
67
73
 
68
74
  # Permissions for the per-session card file (owner-only, matches tree.py).
69
75
  NEW_FILE_MODE = 0o600
76
+ _LOGGER = logging.getLogger(__name__)
77
+ _UNSUPPORTED_SYNC_ERRNOS = {errno.EINVAL, errno.ENOTSUP}
78
+ if hasattr(errno, "EOPNOTSUPP"):
79
+ _UNSUPPORTED_SYNC_ERRNOS.add(errno.EOPNOTSUPP)
70
80
 
71
81
  # Default checklist card id used when ``--id`` is omitted: a session has exactly
72
82
  # one "default" checklist unless the agent names them, which keeps the common
@@ -107,7 +117,7 @@ class CardPaths:
107
117
  cards_dir: Path
108
118
 
109
119
  def session_file(self, session: str) -> Path:
110
- return self.cards_dir / f"{_sanitise_session(session)}.yaml"
120
+ return self.cards_dir / f"{_encode_session(session)}.yaml"
111
121
 
112
122
 
113
123
  # Env override for the cards root, so tests (and a non-default deployment) can
@@ -136,16 +146,10 @@ def resolve_paths(
136
146
  return CardPaths(cards_dir=base_home / ".pocketshell" / "cards")
137
147
 
138
148
 
139
- def _sanitise_session(session: str) -> str:
140
- """Sanitise a tmux session name into a safe single-path-segment filename.
141
-
142
- tmux session names cannot contain whitespace but can contain ``/``, ``.``
143
- etc.; replace anything outside ``[A-Za-z0-9._-]`` so the name maps to one
144
- flat file (no traversal, no nested dirs).
145
- """
146
- safe = re.sub(r"[^A-Za-z0-9._-]+", "_", session.strip())
147
- safe = safe.strip(".") or "session"
148
- return safe
149
+ def _encode_session(session: str) -> str:
150
+ """Reversibly encode a tmux session as one safe path segment."""
151
+ encoded = base64.urlsafe_b64encode(session.encode("utf-8")).decode("ascii")
152
+ return "s-" + encoded.rstrip("=")
149
153
 
150
154
 
151
155
  def _ensure_dir(path: Path) -> None:
@@ -156,25 +160,90 @@ def _ensure_dir(path: Path) -> None:
156
160
  pass
157
161
 
158
162
 
163
+ def _durability_barrier(fd: int, *, path: Path, kind: str) -> bool:
164
+ """Fsync one file or directory, tolerating only unsupported barriers."""
165
+ while True:
166
+ try:
167
+ os.fsync(fd)
168
+ return True
169
+ except OSError as error:
170
+ if error.errno == errno.EINTR:
171
+ continue
172
+ if error.errno in _UNSUPPORTED_SYNC_ERRNOS:
173
+ _LOGGER.warning(
174
+ "durability barrier unavailable for %s %s: %s; "
175
+ "continuing with atomic publication",
176
+ kind,
177
+ path,
178
+ error,
179
+ )
180
+ return False
181
+ raise
182
+
183
+
184
+ def _fsync_directory(path: Path) -> bool:
185
+ """Fsync a directory after rename when the filesystem supports it."""
186
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
187
+ try:
188
+ fd = os.open(str(path), flags)
189
+ except OSError as error:
190
+ if error.errno in _UNSUPPORTED_SYNC_ERRNOS:
191
+ _LOGGER.warning(
192
+ "durability barrier unavailable for directory %s: %s; "
193
+ "continuing with atomic publication",
194
+ path,
195
+ error,
196
+ )
197
+ return False
198
+ raise
199
+ try:
200
+ return _durability_barrier(fd, path=path, kind="directory")
201
+ finally:
202
+ os.close(fd)
203
+
204
+
159
205
  def _write_private(path: Path, text: str) -> None:
160
- """Write ``text`` to ``path`` atomically with mode 0600 (copied from tree.py)."""
206
+ """Publish ``text`` atomically with file and directory durability barriers."""
161
207
  _ensure_dir(path.parent)
162
- tmp = path.with_name(path.name + ".tmp")
163
- fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, NEW_FILE_MODE)
208
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
209
+ tmp = Path(tmp_name)
210
+ open_fd = fd
164
211
  try:
165
- with os.fdopen(fd, "w", encoding="utf-8") as handle:
166
- handle.write(text)
212
+ with os.fdopen(fd, "wb") as handle:
213
+ open_fd = -1
214
+ handle.write(text.encode("utf-8"))
215
+ handle.flush()
216
+ os.chmod(tmp, NEW_FILE_MODE)
217
+ _durability_barrier(handle.fileno(), path=tmp, kind="file")
218
+ os.replace(tmp, path)
219
+ _fsync_directory(path.parent)
167
220
  except BaseException:
221
+ if open_fd >= 0:
222
+ try:
223
+ os.close(open_fd)
224
+ except OSError:
225
+ pass
168
226
  try:
169
- os.unlink(tmp)
227
+ tmp.unlink()
170
228
  except FileNotFoundError:
171
229
  pass
230
+ except OSError:
231
+ pass
172
232
  raise
173
- os.replace(tmp, path)
174
- try:
175
- os.chmod(path, NEW_FILE_MODE)
176
- except FileNotFoundError:
177
- pass
233
+
234
+
235
+ @contextmanager
236
+ def _session_lock(path: Path):
237
+ """Hold the cross-process advisory lock for one session document."""
238
+ _ensure_dir(path.parent)
239
+ lock_path = path.with_suffix(path.suffix + ".lock")
240
+ fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT, NEW_FILE_MODE)
241
+ with os.fdopen(fd, "a+") as lock:
242
+ fcntl.flock(lock, fcntl.LOCK_EX)
243
+ try:
244
+ yield
245
+ finally:
246
+ fcntl.flock(lock, fcntl.LOCK_UN)
178
247
 
179
248
 
180
249
  # ---------------------------------------------------------------------------
@@ -427,9 +496,7 @@ def _import_yaml() -> Any:
427
496
  return yaml
428
497
 
429
498
 
430
- def read_cards(session: str, *, paths: CardPaths) -> list[dict[str, Any]]:
431
- """Return the session's list of cards (empty when none / unreadable)."""
432
- path = paths.session_file(session)
499
+ def _read_cards_file(path: Path, session: str) -> list[dict[str, Any]]:
433
500
  try:
434
501
  raw = path.read_text(encoding="utf-8")
435
502
  except (FileNotFoundError, IsADirectoryError, PermissionError):
@@ -443,14 +510,28 @@ def read_cards(session: str, *, paths: CardPaths) -> list[dict[str, Any]]:
443
510
  return []
444
511
  if not isinstance(doc, Mapping):
445
512
  return []
513
+ if doc.get("session") != session:
514
+ return []
446
515
  cards = doc.get("cards")
447
516
  if not isinstance(cards, list):
448
517
  return []
449
518
  return [dict(c) for c in cards if isinstance(c, Mapping)]
450
519
 
451
520
 
521
+ def read_cards(session: str, *, paths: CardPaths) -> list[dict[str, Any]]:
522
+ """Return the session's list of cards (empty when none / unreadable)."""
523
+ return _read_cards_file(paths.session_file(session), session)
524
+
525
+
452
526
  def write_cards(session: str, cards: list[dict[str, Any]], *, paths: CardPaths) -> Path:
453
527
  """Atomically persist the session's card list as YAML. Returns the path."""
528
+ path = paths.session_file(session)
529
+ with _session_lock(path):
530
+ _write_cards_file(path, session, cards)
531
+ return path
532
+
533
+
534
+ def _write_cards_file(path: Path, session: str, cards: list[dict[str, Any]]) -> None:
454
535
  yaml = _import_yaml()
455
536
  doc = {
456
537
  "schema": 1,
@@ -459,9 +540,22 @@ def write_cards(session: str, cards: list[dict[str, Any]], *, paths: CardPaths)
459
540
  "cards": cards,
460
541
  }
461
542
  text = yaml.safe_dump(doc, sort_keys=False, default_flow_style=False)
462
- path = paths.session_file(session)
463
543
  _write_private(path, text)
464
- return path
544
+
545
+
546
+ def _mutate_cards(
547
+ session: str,
548
+ *,
549
+ paths: CardPaths,
550
+ mutation: Callable[[list[dict[str, Any]]], Any],
551
+ ) -> tuple[Path, Any]:
552
+ """Apply one read-modify-write transaction under the session lock."""
553
+ path = paths.session_file(session)
554
+ with _session_lock(path):
555
+ cards = _read_cards_file(path, session)
556
+ result = mutation(cards)
557
+ _write_cards_file(path, session, cards)
558
+ return path, result
465
559
 
466
560
 
467
561
  # ---------------------------------------------------------------------------
@@ -513,11 +607,13 @@ def upsert_card(session: str, card: dict[str, Any], *, paths: CardPaths) -> Path
513
607
  Replace preserves nothing of the old card — a re-push of a checklist by the
514
608
  agent is a full replace (hard-cut semantics; the new list is authoritative).
515
609
  """
516
- cards = read_cards(session, paths=paths)
517
- card_id = card["id"]
518
- cards = [c for c in cards if c.get("id") != card_id]
519
- cards.append(card)
520
- return write_cards(session, cards, paths=paths)
610
+ def mutate(cards: list[dict[str, Any]]) -> None:
611
+ card_id = card["id"]
612
+ cards[:] = [c for c in cards if c.get("id") != card_id]
613
+ cards.append(card)
614
+
615
+ path, _ = _mutate_cards(session, paths=paths, mutation=mutate)
616
+ return path
521
617
 
522
618
 
523
619
  def apply_interaction(
@@ -532,23 +628,20 @@ def apply_interaction(
532
628
  Raises :class:`ValueError` when the card id is not found in the session, or
533
629
  when the type's :meth:`apply_interaction` rejects the interaction.
534
630
  """
535
- cards = read_cards(session, paths=paths)
536
- target: Optional[dict[str, Any]] = None
537
- for card in cards:
538
- if card.get("id") == card_id:
539
- target = card
540
- break
541
- if target is None:
542
- raise ValueError(f"no card with id {card_id!r} in session {session!r}")
543
- handler = get_card_type(target.get("type", ""))
544
- if handler is None:
545
- raise ValueError(f"card {card_id!r} has unknown type {target.get('type')!r}")
546
- body = target.get("body", {}) if isinstance(target.get("body"), Mapping) else {}
547
- state = target.get("state", {}) if isinstance(target.get("state"), Mapping) else {}
548
- new_state = handler.apply_interaction(dict(body), dict(state), interaction)
549
- target["state"] = new_state
550
- target["updated_at"] = _now_iso()
551
- write_cards(session, cards, paths=paths)
631
+ def mutate(cards: list[dict[str, Any]]) -> dict[str, Any]:
632
+ target = next((card for card in cards if card.get("id") == card_id), None)
633
+ if target is None:
634
+ raise ValueError(f"no card with id {card_id!r} in session {session!r}")
635
+ handler = get_card_type(target.get("type", ""))
636
+ if handler is None:
637
+ raise ValueError(f"card {card_id!r} has unknown type {target.get('type')!r}")
638
+ body = target.get("body", {}) if isinstance(target.get("body"), Mapping) else {}
639
+ state = target.get("state", {}) if isinstance(target.get("state"), Mapping) else {}
640
+ target["state"] = handler.apply_interaction(dict(body), dict(state), interaction)
641
+ target["updated_at"] = _now_iso()
642
+ return target
643
+
644
+ _, target = _mutate_cards(session, paths=paths, mutation=mutate)
552
645
  return target
553
646
 
554
647
 
@@ -0,0 +1,163 @@
1
+ """Host-level PocketShell CLI configuration (``~/.config/pocketshell/config.toml``).
2
+
3
+ Today this file carries exactly one section — ``[backends]`` — the per-host
4
+ policy that decides which session backend a *new* session is created on:
5
+
6
+ .. code-block:: toml
7
+
8
+ [backends]
9
+ agent = "tmux" # sessions created with --engine
10
+ shell = "tmux" # plain shell sessions
11
+
12
+ Why a pocketshell-owned config file rather than a flag or a backend-side
13
+ setting: routing is deployment policy that spans BOTH backends, and neither
14
+ backend can answer "not me" (see the simplification plan, §"Backend routing
15
+ policy"). The phone never sends a backend; the host decides.
16
+
17
+ Defaults are ``tmux`` for both keys. Flipping ``agent = "aplexer"`` is a
18
+ deliberate host config change the maintainer makes — never a code change and
19
+ never an automatic upgrade.
20
+
21
+ Parsing rules (deliberate, see ``load_config``):
22
+
23
+ * missing file / empty file -> defaults;
24
+ * malformed TOML, a non-table ``[backends]``, or an unknown backend value ->
25
+ :class:`ConfigError`, i.e. **fail loud**. Silently falling back to the
26
+ defaults would route agent sessions to tmux while the host owner believes
27
+ aplexer is switched on — a wrong-path failure that shows up as "the feature
28
+ just doesn't work", which is exactly the class of silent misroute this
29
+ project rejects. A typo in a two-line file is cheap to fix once it is
30
+ reported; a silent misroute is not.
31
+
32
+ ``tomllib`` is used unconditionally: this package's ``requires-python`` is
33
+ ``>=3.11``, where ``tomllib`` is stdlib, so there is no ``tomli`` fallback to
34
+ maintain (D22: no compatibility shims).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import os
40
+ import tomllib
41
+ from pathlib import Path
42
+ from typing import Any, Mapping, Optional
43
+
44
+ #: Session backends this CLI knows how to create on.
45
+ BACKEND_TMUX = "tmux"
46
+ BACKEND_APLEXER = "aplexer"
47
+ BACKENDS = (BACKEND_TMUX, BACKEND_APLEXER)
48
+
49
+ #: Backend used when the config file says nothing.
50
+ DEFAULT_BACKEND = BACKEND_TMUX
51
+
52
+ #: ``[backends]`` keys: ``agent`` covers a session launched with an engine,
53
+ #: ``shell`` a plain shell session.
54
+ BACKEND_KEY_AGENT = "agent"
55
+ BACKEND_KEY_SHELL = "shell"
56
+ BACKEND_KEYS = (BACKEND_KEY_AGENT, BACKEND_KEY_SHELL)
57
+
58
+ DEFAULT_BACKENDS: dict[str, str] = {
59
+ BACKEND_KEY_AGENT: DEFAULT_BACKEND,
60
+ BACKEND_KEY_SHELL: DEFAULT_BACKEND,
61
+ }
62
+
63
+
64
+ class ConfigError(RuntimeError):
65
+ """``config.toml`` exists but cannot be trusted (malformed / bad value)."""
66
+
67
+
68
+ def _env_source(env: Optional[Mapping[str, str]] = None) -> Mapping[str, str]:
69
+ return env if env is not None else os.environ
70
+
71
+
72
+ def _home_dir(env: Optional[Mapping[str, str]] = None) -> Path:
73
+ """Resolve ``$HOME`` (honouring an injected env for tests)."""
74
+ home = _env_source(env).get("HOME")
75
+ if home:
76
+ return Path(home)
77
+ return Path(os.path.expanduser("~"))
78
+
79
+
80
+ def config_path(env: Optional[Mapping[str, str]] = None) -> Path:
81
+ """``$XDG_CONFIG_HOME``/``~/.config`` + ``pocketshell/config.toml``.
82
+
83
+ Same resolution order as ``profiles.py``'s ``profiles.yaml`` so a host
84
+ keeps all PocketShell config in one directory.
85
+ """
86
+ xdg = _env_source(env).get("XDG_CONFIG_HOME")
87
+ base = Path(xdg) if xdg else _home_dir(env) / ".config"
88
+ return base / "pocketshell" / "config.toml"
89
+
90
+
91
+ def default_config() -> dict[str, Any]:
92
+ """A fresh copy of the fully-defaulted config document."""
93
+ return {"backends": dict(DEFAULT_BACKENDS)}
94
+
95
+
96
+ def _normalize_backends(raw: Any, *, path: Path) -> dict[str, str]:
97
+ """Validate the ``[backends]`` table and merge it over the defaults."""
98
+ backends = dict(DEFAULT_BACKENDS)
99
+ if raw is None:
100
+ return backends
101
+ if not isinstance(raw, Mapping):
102
+ raise ConfigError(
103
+ f"{path}: [backends] must be a table, got {type(raw).__name__}"
104
+ )
105
+ for key, value in raw.items():
106
+ if key not in BACKEND_KEYS:
107
+ raise ConfigError(
108
+ f"{path}: unknown key [backends].{key} "
109
+ f"(known keys: {', '.join(BACKEND_KEYS)})"
110
+ )
111
+ if not isinstance(value, str) or value.strip() not in BACKENDS:
112
+ raise ConfigError(
113
+ f"{path}: [backends].{key} must be one of "
114
+ f"{', '.join(BACKENDS)}, got {value!r}"
115
+ )
116
+ backends[key] = value.strip()
117
+ return backends
118
+
119
+
120
+ def load_config(env: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
121
+ """Read ``~/.config/pocketshell/config.toml``.
122
+
123
+ Returns the parsed document with a fully-populated ``backends`` table
124
+ (every key present, defaults filled in) so callers never have to probe for
125
+ key presence. Any other top-level table the file happens to carry is
126
+ passed through untouched — this loader owns ``[backends]`` only.
127
+
128
+ A missing file returns the defaults. A file that exists but is malformed
129
+ (bad TOML, non-table ``[backends]``, unknown key, unknown backend value,
130
+ unreadable) raises :class:`ConfigError` — see the module docstring for why
131
+ this fails loud instead of degrading to defaults.
132
+ """
133
+ path = config_path(env)
134
+ if not path.is_file():
135
+ return default_config()
136
+ try:
137
+ raw_bytes = path.read_bytes()
138
+ except OSError as exc:
139
+ raise ConfigError(f"{path}: cannot be read: {exc}") from exc
140
+ try:
141
+ document = tomllib.loads(raw_bytes.decode("utf-8"))
142
+ except (UnicodeDecodeError, tomllib.TOMLDecodeError) as exc:
143
+ raise ConfigError(f"{path}: is not valid TOML: {exc}") from exc
144
+ if not isinstance(document, dict): # pragma: no cover - tomllib invariant
145
+ raise ConfigError(f"{path}: top level must be a table")
146
+ normalized = dict(document)
147
+ normalized["backends"] = _normalize_backends(document.get("backends"), path=path)
148
+ return normalized
149
+
150
+
151
+ def backend_for(config: Mapping[str, Any], key: str) -> str:
152
+ """Read one ``[backends]`` key out of a (possibly partial) config dict.
153
+
154
+ Tolerant on purpose: :func:`load_config` is the strict validator, and this
155
+ accessor is also handed hand-built dicts (tests, callers that skipped the
156
+ loader). Anything missing or not a usable string resolves to
157
+ :data:`DEFAULT_BACKEND`.
158
+ """
159
+ backends = config.get("backends") if isinstance(config, Mapping) else None
160
+ value = backends.get(key) if isinstance(backends, Mapping) else None
161
+ if isinstance(value, str) and value.strip():
162
+ return value.strip()
163
+ return DEFAULT_BACKEND