pocketshell 0.4.14__tar.gz → 0.4.17__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 (57) hide show
  1. {pocketshell-0.4.14 → pocketshell-0.4.17}/PKG-INFO +1 -1
  2. {pocketshell-0.4.14 → pocketshell-0.4.17}/pyproject.toml +1 -1
  3. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/cards.py +124 -0
  4. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_cards.py +136 -0
  5. {pocketshell-0.4.14 → pocketshell-0.4.17}/uv.lock +3 -2
  6. {pocketshell-0.4.14 → pocketshell-0.4.17}/.gitignore +0 -0
  7. {pocketshell-0.4.14 → pocketshell-0.4.17}/README.md +0 -0
  8. {pocketshell-0.4.14 → pocketshell-0.4.17}/scheduler/README.md +0 -0
  9. {pocketshell-0.4.14 → pocketshell-0.4.17}/scheduler/pocketshell-usage-capture.service +0 -0
  10. {pocketshell-0.4.14 → pocketshell-0.4.17}/scheduler/pocketshell-usage-capture.timer +0 -0
  11. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/__init__.py +0 -0
  12. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/__main__.py +0 -0
  13. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/agent_log.py +0 -0
  14. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/agents.py +0 -0
  15. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/agents_kind.py +0 -0
  16. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/cgroup_agents.py +0 -0
  17. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/cli.py +0 -0
  18. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/daemon.py +0 -0
  19. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/env.py +0 -0
  20. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/github.py +0 -0
  21. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/hooks.py +0 -0
  22. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/jobs.py +0 -0
  23. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/logs.py +0 -0
  24. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/profiles.py +0 -0
  25. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/prune_attachments.py +0 -0
  26. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/push.py +0 -0
  27. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/qr_share.py +0 -0
  28. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/repos.py +0 -0
  29. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/resume.py +0 -0
  30. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/sessions.py +0 -0
  31. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/tree.py +0 -0
  32. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/usage.py +0 -0
  33. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/usage_capture.py +0 -0
  34. {pocketshell-0.4.14 → pocketshell-0.4.17}/src/pocketshell/usage_reset.py +0 -0
  35. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/__init__.py +0 -0
  36. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_agent_log.py +0 -0
  37. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_agents.py +0 -0
  38. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_agents_kind.py +0 -0
  39. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_cgroup_agents.py +0 -0
  40. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_cli.py +0 -0
  41. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_daemon.py +0 -0
  42. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_env.py +0 -0
  43. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_github.py +0 -0
  44. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_hooks.py +0 -0
  45. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_jobs.py +0 -0
  46. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_logs.py +0 -0
  47. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_profiles.py +0 -0
  48. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_prune_attachments.py +0 -0
  49. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_push.py +0 -0
  50. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_qr_share.py +0 -0
  51. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_repos.py +0 -0
  52. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_resume.py +0 -0
  53. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_sessions.py +0 -0
  54. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_tree.py +0 -0
  55. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_usage.py +0 -0
  56. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_usage_capture.py +0 -0
  57. {pocketshell-0.4.14 → pocketshell-0.4.17}/tests/test_usage_reset.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.4.14
3
+ Version: 0.4.17
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
@@ -8,7 +8,7 @@ name = "pocketshell"
8
8
  # scripts/check-pypi-version.sh enforces this; .github/workflows/build.yml
9
9
  # runs that check before publishing to PyPI. See
10
10
  # tools/pocketshell/README.md ("Release flow") for the bump procedure.
11
- version = "0.4.14"
11
+ version = "0.4.17"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -360,6 +360,62 @@ register_card_type(
360
360
  )
361
361
 
362
362
 
363
+ # ---------------------------------------------------------------------------
364
+ # note card type (#859 Slice B — proves the registry is genuinely generic)
365
+ # ---------------------------------------------------------------------------
366
+ #
367
+ # A ``note`` is a non-interactive message the agent hands the human ("deploy
368
+ # finished", "found a flaky test"). Its only interaction is mark-as-read, so the
369
+ # agent can tell from ``push status`` whether the human has seen it. Registering
370
+ # it is exactly one ``register_card_type`` call + a ``push note`` verb — no new
371
+ # transport, no change to the store/read/write/upsert path. That is the whole
372
+ # point of the registry: a second real type is additive.
373
+
374
+ # Default note card id used when ``--id`` is omitted (one default note per
375
+ # session unless the agent names them — mirrors DEFAULT_CHECKLIST_ID).
376
+ DEFAULT_NOTE_ID = "note"
377
+
378
+
379
+ def _note_build_body(*, text: str, **_: Any) -> dict[str, Any]:
380
+ """Build the note ``body`` — ``{"text": <message>}``."""
381
+ return {"text": text}
382
+
383
+
384
+ def _note_initial_state(_body: dict[str, Any]) -> dict[str, Any]:
385
+ """A fresh note is unread."""
386
+ return {"read": False, "read_at": None}
387
+
388
+
389
+ def _note_apply_interaction(
390
+ body: dict[str, Any],
391
+ state: dict[str, Any],
392
+ interaction: Mapping[str, Any],
393
+ ) -> dict[str, Any]:
394
+ """Mark a note read/unread.
395
+
396
+ ``interaction = {"read": bool}``. ``read`` defaults to True (mark read).
397
+ ``read_at`` records when it was first read (cleared on unread) so the agent
398
+ can see acknowledgement timing via ``push status``.
399
+ """
400
+ read = bool(interaction.get("read", True))
401
+ return {"read": read, "read_at": _now_iso() if read else None}
402
+
403
+
404
+ def _note_summarise(body: dict[str, Any], state: dict[str, Any]) -> str:
405
+ return "note: read" if state.get("read") else "note: unread"
406
+
407
+
408
+ register_card_type(
409
+ CardType(
410
+ name="note",
411
+ build_body=_note_build_body,
412
+ initial_state=_note_initial_state,
413
+ apply_interaction=_note_apply_interaction,
414
+ summarise=_note_summarise,
415
+ )
416
+ )
417
+
418
+
363
419
  # ---------------------------------------------------------------------------
364
420
  # Store read/write
365
421
  # ---------------------------------------------------------------------------
@@ -661,3 +717,71 @@ def register_push_card_commands(push_group: click.Group) -> None:
661
717
  state = card.get("state", {}) if isinstance(card.get("state"), Mapping) else {}
662
718
  summary = handler.summarise(dict(body), dict(state)) if handler else ""
663
719
  click.echo(f"{card_id}: {summary}")
720
+
721
+ @push_group.command("note")
722
+ @click.option("--title", "title", default=None, help="Human title for the note card.")
723
+ @click.option(
724
+ "--id",
725
+ "card_id",
726
+ default=DEFAULT_NOTE_ID,
727
+ help=f"Card id (default {DEFAULT_NOTE_ID!r}; one default note per session).",
728
+ )
729
+ @click.option(
730
+ "--text",
731
+ "text",
732
+ default=None,
733
+ help="The note body. Alternative to piping the message on stdin.",
734
+ )
735
+ @click.option("--session", "session", default=None, help="Override the auto-detected tmux session.")
736
+ def push_note(
737
+ title: Optional[str],
738
+ card_id: str,
739
+ text: Optional[str],
740
+ session: Optional[str],
741
+ ) -> None:
742
+ """Create/replace the session's note card from --text or piped stdin.
743
+
744
+ A note is a non-interactive message the human marks read (``push read``).
745
+ The session is auto-detected from ``$TMUX`` (override with ``--session``).
746
+ A re-push fully replaces the card of that id (hard-cut, D22).
747
+ """
748
+ target = _require_session(session)
749
+ if text is not None and text.strip():
750
+ body_text = text.strip()
751
+ else:
752
+ stdin_text = "" if sys.stdin is None or sys.stdin.isatty() else sys.stdin.read()
753
+ body_text = stdin_text.strip()
754
+ if not body_text:
755
+ raise click.ClickException(
756
+ "empty note: pass --text or pipe the message on stdin."
757
+ )
758
+ card = build_card(
759
+ card_type="note",
760
+ card_id=card_id,
761
+ title=title,
762
+ build_kwargs={"text": body_text},
763
+ )
764
+ path = upsert_card(target, card, paths=resolve_paths())
765
+ click.echo(f"note {card_id!r} -> session {target!r} ({path})")
766
+
767
+ @push_group.command("read")
768
+ @click.option("--id", "card_id", required=True, help="The note card id.")
769
+ @click.option("--read/--unread", "read", default=True, help="Mark read (default) or unread.")
770
+ @click.option("--session", "session", default=None, help="Override the auto-detected tmux session.")
771
+ def push_read(card_id: str, read: bool, session: Optional[str]) -> None:
772
+ """Set a note's read state (this is what the app's "mark read" calls)."""
773
+ target = _require_session(session)
774
+ try:
775
+ card = apply_interaction(
776
+ target,
777
+ card_id,
778
+ {"read": read},
779
+ paths=resolve_paths(),
780
+ )
781
+ except ValueError as exc:
782
+ raise click.ClickException(str(exc))
783
+ handler = get_card_type(card.get("type", ""))
784
+ body = card.get("body", {}) if isinstance(card.get("body"), Mapping) else {}
785
+ state = card.get("state", {}) if isinstance(card.get("state"), Mapping) else {}
786
+ summary = handler.summarise(dict(body), dict(state)) if handler else ""
787
+ click.echo(f"{card_id}: {summary}")
@@ -389,3 +389,139 @@ def test_cli_check_unknown_item_errors(tmp_path: Path) -> None:
389
389
  )
390
390
  assert res.exit_code != 0
391
391
  assert "unknown item" in res.output
392
+
393
+
394
+ # ----- note card type (#859 Slice B — registry is genuinely generic) ----
395
+
396
+
397
+ def test_note_card_type_is_registered() -> None:
398
+ """AC: the host registers a REAL `note` CardType (not just the stub test)."""
399
+ handler = cards_mod.get_card_type("note")
400
+ assert handler is not None
401
+ assert handler.name == "note"
402
+
403
+
404
+ def test_build_upsert_read_note(tmp_path: Path) -> None:
405
+ """AC: note `body={text}`, `state={read:false, read_at:None}` round-trips."""
406
+ paths = _paths(tmp_path)
407
+ card = cards_mod.build_card(
408
+ card_type="note",
409
+ card_id="note",
410
+ title="Heads up",
411
+ build_kwargs={"text": "deploy finished"},
412
+ )
413
+ cards_mod.upsert_card("demo", card, paths=paths)
414
+ got = cards_mod.read_cards("demo", paths=paths)
415
+ assert len(got) == 1
416
+ assert got[0]["type"] == "note"
417
+ assert got[0]["title"] == "Heads up"
418
+ assert got[0]["body"] == {"text": "deploy finished"}
419
+ assert got[0]["state"] == {"read": False, "read_at": None}
420
+
421
+
422
+ def test_note_apply_interaction_marks_read_and_unread(tmp_path: Path) -> None:
423
+ """AC: mark-as-read interaction sets read + read_at; unread clears read_at."""
424
+ paths = _paths(tmp_path)
425
+ card = cards_mod.build_card(
426
+ card_type="note", card_id="note", title=None,
427
+ build_kwargs={"text": "hi"},
428
+ )
429
+ cards_mod.upsert_card("demo", card, paths=paths)
430
+
431
+ read = cards_mod.apply_interaction("demo", "note", {"read": True}, paths=paths)
432
+ assert read["state"]["read"] is True
433
+ assert read["state"]["read_at"] # a timestamp, not None
434
+
435
+ unread = cards_mod.apply_interaction("demo", "note", {"read": False}, paths=paths)
436
+ assert unread["state"]["read"] is False
437
+ assert unread["state"]["read_at"] is None
438
+
439
+
440
+ def test_note_and_checklist_coexist_in_one_session(tmp_path: Path) -> None:
441
+ """The store is type-agnostic: a note + a checklist live side by side."""
442
+ paths = _paths(tmp_path)
443
+ checklist = cards_mod.build_card(
444
+ card_type="checklist", card_id="checklist", title="Deploy",
445
+ build_kwargs={"items": [{"id": "build-0", "text": "build"}]},
446
+ )
447
+ note = cards_mod.build_card(
448
+ card_type="note", card_id="note", title=None,
449
+ build_kwargs={"text": "fyi"},
450
+ )
451
+ cards_mod.upsert_card("demo", checklist, paths=paths)
452
+ cards_mod.upsert_card("demo", note, paths=paths)
453
+ got = cards_mod.read_cards("demo", paths=paths)
454
+ assert {c["type"] for c in got} == {"checklist", "note"}
455
+
456
+
457
+ def test_cli_note_set_get_read_status_flow(tmp_path: Path) -> None:
458
+ """AC: push note (stdin) → get --json → read → status reflects read."""
459
+ runner = CliRunner()
460
+ env = _env(tmp_path)
461
+
462
+ res = runner.invoke(
463
+ cli,
464
+ ["push", "note", "--title", "Heads up", "--session", "demo"],
465
+ input="deploy finished\n",
466
+ env=env,
467
+ )
468
+ assert res.exit_code == 0, res.output
469
+ assert "note 'note'" in res.output
470
+
471
+ res = runner.invoke(cli, ["push", "get", "--json", "--session", "demo"], env=env)
472
+ assert res.exit_code == 0, res.output
473
+ payload = json.loads(res.output)
474
+ card = payload["cards"][0]
475
+ assert card["type"] == "note"
476
+ assert card["title"] == "Heads up"
477
+ assert card["body"]["text"] == "deploy finished"
478
+ assert card["state"]["read"] is False
479
+
480
+ # Human marks it read (this is what the app's "mark read" calls).
481
+ res = runner.invoke(
482
+ cli, ["push", "read", "--id", "note", "--session", "demo"], env=env
483
+ )
484
+ assert res.exit_code == 0, res.output
485
+ assert "note: read" in res.output
486
+
487
+ # Agent reads the acknowledgement via status.
488
+ res = runner.invoke(cli, ["push", "status", "--json", "--session", "demo"], env=env)
489
+ assert res.exit_code == 0, res.output
490
+ status = json.loads(res.output)
491
+ assert status["status"][0]["state"]["read"] is True
492
+
493
+ res = runner.invoke(cli, ["push", "status", "--session", "demo"], env=env)
494
+ assert res.exit_code == 0, res.output
495
+ assert "note: read" in res.output
496
+
497
+
498
+ def test_cli_note_text_via_flag(tmp_path: Path) -> None:
499
+ runner = CliRunner()
500
+ env = _env(tmp_path)
501
+ res = runner.invoke(
502
+ cli, ["push", "note", "--text", "found a flaky test", "--session", "demo"],
503
+ env=env,
504
+ )
505
+ assert res.exit_code == 0, res.output
506
+ got = cards_mod.read_cards("demo", paths=_paths(tmp_path))
507
+ assert got[0]["body"]["text"] == "found a flaky test"
508
+
509
+
510
+ def test_cli_note_empty_errors(tmp_path: Path) -> None:
511
+ runner = CliRunner()
512
+ env = _env(tmp_path)
513
+ res = runner.invoke(
514
+ cli, ["push", "note", "--session", "demo"], input="", env=env
515
+ )
516
+ assert res.exit_code != 0
517
+ assert "empty note" in res.output
518
+
519
+
520
+ def test_cli_read_unknown_card_errors(tmp_path: Path) -> None:
521
+ runner = CliRunner()
522
+ env = _env(tmp_path)
523
+ res = runner.invoke(
524
+ cli, ["push", "read", "--id", "ghost", "--session", "demo"], env=env
525
+ )
526
+ assert res.exit_code != 0
527
+ assert "no card" in res.output
@@ -3,7 +3,8 @@ revision = 3
3
3
  requires-python = ">=3.11"
4
4
 
5
5
  [options]
6
- exclude-newer = "2026-12-31T23:00:00Z"
6
+ exclude-newer = "2026-06-18T14:08:51.166593044Z"
7
+ exclude-newer-span = "P7D"
7
8
 
8
9
  [[package]]
9
10
  name = "annotated-doc"
@@ -314,7 +315,7 @@ wheels = [
314
315
 
315
316
  [[package]]
316
317
  name = "pocketshell"
317
- version = "0.4.14"
318
+ version = "0.4.16"
318
319
  source = { editable = "." }
319
320
  dependencies = [
320
321
  { name = "click" },
File without changes
File without changes