pcli-agent 0.1.0__py3-none-any.whl

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 (130) hide show
  1. pcli/__init__.py +1 -0
  2. pcli/__main__.py +4 -0
  3. pcli/agent/__init__.py +0 -0
  4. pcli/agent/activity.py +116 -0
  5. pcli/agent/compaction.py +205 -0
  6. pcli/agent/context_pruning.py +88 -0
  7. pcli/agent/headless.py +209 -0
  8. pcli/agent/loop.py +442 -0
  9. pcli/agent/prompt.py +371 -0
  10. pcli/agent/runtime.py +240 -0
  11. pcli/browser/__init__.py +0 -0
  12. pcli/browser/session.py +135 -0
  13. pcli/cli.py +757 -0
  14. pcli/config/__init__.py +0 -0
  15. pcli/config/paths.py +95 -0
  16. pcli/config/settings.py +435 -0
  17. pcli/cost/__init__.py +0 -0
  18. pcli/cost/context.py +275 -0
  19. pcli/cost/context_detect.py +183 -0
  20. pcli/cost/pricing_table.py +141 -0
  21. pcli/cost/tracker.py +126 -0
  22. pcli/llm/__init__.py +0 -0
  23. pcli/llm/client.py +285 -0
  24. pcli/llm/errors.py +37 -0
  25. pcli/llm/models.py +100 -0
  26. pcli/llm/streaming.py +108 -0
  27. pcli/memory/__init__.py +0 -0
  28. pcli/memory/extraction.py +106 -0
  29. pcli/memory/models.py +103 -0
  30. pcli/memory/store.py +88 -0
  31. pcli/permissions/__init__.py +0 -0
  32. pcli/permissions/guardrails.py +219 -0
  33. pcli/permissions/manager.py +215 -0
  34. pcli/permissions/policy.py +70 -0
  35. pcli/sandbox/__init__.py +0 -0
  36. pcli/sandbox/base.py +50 -0
  37. pcli/sandbox/docker_backend.py +107 -0
  38. pcli/sandbox/limits.py +63 -0
  39. pcli/sandbox/null_backend.py +92 -0
  40. pcli/sandbox/selector.py +75 -0
  41. pcli/sandbox/subprocess_backend.py +376 -0
  42. pcli/scheduler/__init__.py +0 -0
  43. pcli/scheduler/daemon.py +194 -0
  44. pcli/scheduler/models.py +97 -0
  45. pcli/scheduler/runner.py +84 -0
  46. pcli/scheduler/store.py +75 -0
  47. pcli/scheduler/triggers.py +84 -0
  48. pcli/session/__init__.py +0 -0
  49. pcli/session/audit.py +122 -0
  50. pcli/session/directory_check.py +28 -0
  51. pcli/session/export.py +57 -0
  52. pcli/session/importer.py +92 -0
  53. pcli/session/models.py +168 -0
  54. pcli/session/store.py +127 -0
  55. pcli/telegram/__init__.py +0 -0
  56. pcli/telegram/bot.py +266 -0
  57. pcli/telegram/daemon.py +1197 -0
  58. pcli/telegram/permissions.py +131 -0
  59. pcli/telegram/sender.py +58 -0
  60. pcli/tools/__init__.py +0 -0
  61. pcli/tools/_nested_agent.py +204 -0
  62. pcli/tools/agent_tools.py +264 -0
  63. pcli/tools/agent_tools_store.py +69 -0
  64. pcli/tools/artifacts.py +47 -0
  65. pcli/tools/base.py +185 -0
  66. pcli/tools/builtin/__init__.py +0 -0
  67. pcli/tools/builtin/agent_tool_register_tool.py +100 -0
  68. pcli/tools/builtin/artifact_tool.py +212 -0
  69. pcli/tools/builtin/ask_tool.py +77 -0
  70. pcli/tools/builtin/browser_tool.py +253 -0
  71. pcli/tools/builtin/decision_tool.py +73 -0
  72. pcli/tools/builtin/describe_tool.py +389 -0
  73. pcli/tools/builtin/diff_tools.py +225 -0
  74. pcli/tools/builtin/fs_tools.py +371 -0
  75. pcli/tools/builtin/grep_tool.py +88 -0
  76. pcli/tools/builtin/memory_tool.py +108 -0
  77. pcli/tools/builtin/network_tools.py +107 -0
  78. pcli/tools/builtin/pip_tool.py +106 -0
  79. pcli/tools/builtin/shell_tool.py +240 -0
  80. pcli/tools/builtin/subagent_tool.py +146 -0
  81. pcli/tools/builtin/todo_tool.py +122 -0
  82. pcli/tools/builtin/toolbox_register_tool.py +76 -0
  83. pcli/tools/builtin/web_tools.py +322 -0
  84. pcli/tools/pydiscovery/__init__.py +0 -0
  85. pcli/tools/pydiscovery/cache.py +51 -0
  86. pcli/tools/pydiscovery/index.py +48 -0
  87. pcli/tools/pydiscovery/invoke.py +181 -0
  88. pcli/tools/pydiscovery/search.py +117 -0
  89. pcli/tools/registry.py +138 -0
  90. pcli/tools/toolbox/__init__.py +0 -0
  91. pcli/tools/toolbox/introspect.py +48 -0
  92. pcli/tools/toolbox/manager.py +336 -0
  93. pcli/tools/toolbox/plugin_base.py +51 -0
  94. pcli/tools/toolbox/plugins/__init__.py +6 -0
  95. pcli/tools/toolbox/plugins/httpd.py +99 -0
  96. pcli/tools/toolbox/plugins/kafka.py +162 -0
  97. pcli/tools/toolbox/plugins/kubectl.py +211 -0
  98. pcli/tools/toolbox/plugins/sge.py +146 -0
  99. pcli/tools/toolbox/store.py +65 -0
  100. pcli/tools/toolbox/synthesize.py +100 -0
  101. pcli/tui/__init__.py +0 -0
  102. pcli/tui/app.py +37 -0
  103. pcli/tui/screens/__init__.py +0 -0
  104. pcli/tui/screens/ask_question_modal.py +54 -0
  105. pcli/tui/screens/chat.py +2070 -0
  106. pcli/tui/screens/confirm_modal.py +39 -0
  107. pcli/tui/screens/models.py +43 -0
  108. pcli/tui/screens/permission_modal.py +71 -0
  109. pcli/tui/screens/sessions.py +162 -0
  110. pcli/tui/screens/subagent_activity_modal.py +71 -0
  111. pcli/tui/shell_passthrough.py +56 -0
  112. pcli/tui/styles/pcli.tcss +241 -0
  113. pcli/tui/themes.py +84 -0
  114. pcli/tui/widgets/__init__.py +0 -0
  115. pcli/tui/widgets/chat_input.py +240 -0
  116. pcli/tui/widgets/command_suggestions.py +33 -0
  117. pcli/tui/widgets/message_view.py +328 -0
  118. pcli/tui/widgets/paste_input.py +99 -0
  119. pcli/tui/widgets/paste_marker.py +69 -0
  120. pcli/tui/widgets/status_bar.py +133 -0
  121. pcli/tui/widgets/status_pane.py +58 -0
  122. pcli/util/__init__.py +0 -0
  123. pcli/util/ids.py +15 -0
  124. pcli/util/logging.py +18 -0
  125. pcli/util/text.py +10 -0
  126. pcli_agent-0.1.0.dist-info/METADATA +259 -0
  127. pcli_agent-0.1.0.dist-info/RECORD +130 -0
  128. pcli_agent-0.1.0.dist-info/WHEEL +4 -0
  129. pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  130. pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,84 @@
1
+ """Executes one task end-to-end against the headless runtime - the shared
2
+ implementation behind both `pcli run` (cli.py) and a scheduled job firing
3
+ (scheduler/daemon.py). Factored out so the two entry points can't drift:
4
+ both build the same AgentRuntime, load/create the session the same way, run
5
+ the same run_headless_task, and send the same optional Telegram
6
+ notification.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from pcli.agent.headless import (
14
+ HeadlessTurnResult,
15
+ ProgressCallback,
16
+ new_headless_session,
17
+ run_headless_task,
18
+ )
19
+ from pcli.agent.runtime import build_agent_runtime, build_permission_manager
20
+ from pcli.config.settings import Settings
21
+ from pcli.session.store import SessionNotFoundError, SessionStore
22
+ from pcli.telegram.bot import notify_telegram
23
+
24
+
25
+ class ScheduledSessionNotFoundError(Exception):
26
+ """Raised when a job's session_id no longer resolves to a real session
27
+ (e.g. it was deleted via `pcli sessions`) - the caller decides whether
28
+ that's fatal for this run or should fall back to a fresh session."""
29
+
30
+
31
+ async def run_task_once(
32
+ task: str,
33
+ *,
34
+ settings: Settings,
35
+ store: SessionStore,
36
+ cwd: Path,
37
+ session_id: str | None = None,
38
+ quiet: bool = True,
39
+ headed: bool = False,
40
+ notify_telegram_flag: bool = False,
41
+ on_progress: ProgressCallback = lambda _line: None,
42
+ ) -> HeadlessTurnResult:
43
+ """Builds a fresh AgentRuntime, loads/creates the session, runs the
44
+ task, optionally notifies Telegram, and cleanly closes the runtime -
45
+ the same sequence `pcli run` has always done, now also used by a fired
46
+ scheduled job. Raises ScheduledSessionNotFoundError if session_id is
47
+ given but doesn't resolve (the runtime is still cleaned up first)."""
48
+ if session_id:
49
+ try:
50
+ session = store.load(session_id)
51
+ except SessionNotFoundError:
52
+ raise ScheduledSessionNotFoundError(session_id) from None
53
+ else:
54
+ session = new_headless_session(store, settings, cwd)
55
+
56
+ runtime = await build_agent_runtime(settings, cwd, browser_headless=not headed)
57
+ try:
58
+ result = await run_headless_task(
59
+ task,
60
+ session=session,
61
+ runtime=runtime,
62
+ settings=settings,
63
+ permission_manager=build_permission_manager(settings),
64
+ cwd=cwd,
65
+ store=store,
66
+ on_progress=on_progress,
67
+ )
68
+ finally:
69
+ await runtime.client.aclose()
70
+ await runtime.browser_session.close()
71
+
72
+ if notify_telegram_flag:
73
+ if not settings.is_telegram_configured():
74
+ on_progress(
75
+ "notify_telegram was requested but telegram_bot_token/telegram_chat_id aren't "
76
+ "configured - skipping the notification."
77
+ )
78
+ else:
79
+ try:
80
+ await notify_telegram(settings, result.final_text)
81
+ except Exception as exc: # noqa: BLE001 - a failed notification shouldn't fail the run
82
+ on_progress(f"Failed to send the Telegram notification: {exc}")
83
+
84
+ return result
@@ -0,0 +1,75 @@
1
+ """On-disk persistence for `pcli schedule`'s job list (scheduler/models.py) -
2
+ mirrors memory/store.py's shape (a single JSON file under data_dir(),
3
+ atomic-written, loaded fresh on every call rather than cached) so `pcli
4
+ schedule add` from one process and `pcli schedule run`'s daemon loop in
5
+ another always see each other's latest writes without needing to share
6
+ in-memory state or restart anything."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from pcli.config.paths import schedule_file
14
+ from pcli.scheduler.models import ScheduleJob, ScheduleStore
15
+
16
+
17
+ def _atomic_write(path: Path, content: str) -> None:
18
+ path.parent.mkdir(parents=True, exist_ok=True)
19
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
20
+ tmp_path.write_text(content, encoding="utf-8")
21
+ os.replace(tmp_path, path)
22
+
23
+
24
+ def read_schedule() -> ScheduleStore:
25
+ path = schedule_file()
26
+ if not path.exists():
27
+ return ScheduleStore()
28
+ return ScheduleStore.model_validate_json(path.read_text(encoding="utf-8"))
29
+
30
+
31
+ def write_schedule(store: ScheduleStore) -> None:
32
+ _atomic_write(schedule_file(), store.model_dump_json(indent=2))
33
+
34
+
35
+ def add_job(job: ScheduleJob) -> ScheduleJob:
36
+ store = read_schedule()
37
+ store.jobs.append(job)
38
+ write_schedule(store)
39
+ return job
40
+
41
+
42
+ def remove_job(job_id: str) -> bool:
43
+ """Returns whether anything was removed."""
44
+ store = read_schedule()
45
+ before = len(store.jobs)
46
+ store.jobs = [j for j in store.jobs if j.id != job_id]
47
+ if len(store.jobs) == before:
48
+ return False
49
+ write_schedule(store)
50
+ return True
51
+
52
+
53
+ def get_job(job_id: str) -> ScheduleJob | None:
54
+ return next((j for j in read_schedule().jobs if j.id == job_id), None)
55
+
56
+
57
+ def set_job_enabled(job_id: str, enabled: bool) -> bool:
58
+ """Returns whether a matching job was found and updated."""
59
+ store = read_schedule()
60
+ for job in store.jobs:
61
+ if job.id == job_id:
62
+ job.enabled = enabled
63
+ write_schedule(store)
64
+ return True
65
+ return False
66
+
67
+
68
+ def update_job(job: ScheduleJob) -> None:
69
+ """Replaces the stored job with the same id as `job` - used to persist
70
+ daemon-side bookkeeping (next_run_at/last_run_at/last_status/last_error)
71
+ after a run, without the caller needing to hand-roll the read/mutate/
72
+ write cycle itself."""
73
+ store = read_schedule()
74
+ store.jobs = [job if j.id == job.id else j for j in store.jobs]
75
+ write_schedule(store)
@@ -0,0 +1,84 @@
1
+ """Signature computation for the two event-based ScheduleJob trigger kinds
2
+ (scheduler/models.py's "file_change"/"git_commit") - both are polled by
3
+ scheduler/daemon.py exactly like a cron job's next_run_at, just comparing a
4
+ cheap signature instead of a timestamp. No new dependency: file signatures
5
+ are plain os.stat() calls (no file contents read, binary-safe), and git
6
+ commit lookups just shell out to `git rev-parse` - there's nothing here a
7
+ file-watching library or GitPython would meaningfully simplify for a single
8
+ poll-driven check."""
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import logging
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _MISSING_PATH_SENTINEL = "missing"
20
+
21
+
22
+ def compute_path_signature(path: Path) -> str:
23
+ """A sha256 hex digest over the sorted (relative_path, mtime_ns, size)
24
+ of every file under `path` (or just `path` itself if it's a file) -
25
+ changes whenever a watched file is added, removed, or modified,
26
+ without reading any file's actual contents. Any path component named
27
+ ".git" is skipped, so watching a repo's working tree doesn't fire on
28
+ the repo's own internal bookkeeping. A path that doesn't exist yet (or
29
+ no longer exists) gets a constant sentinel signature, so "still
30
+ missing" is never treated as a change - only the path reappearing is."""
31
+ if not path.exists():
32
+ return _MISSING_PATH_SENTINEL
33
+
34
+ if path.is_file():
35
+ files = [path]
36
+ else:
37
+ files = sorted(p for p in path.rglob("*") if p.is_file() and ".git" not in p.parts)
38
+
39
+ entries = []
40
+ for file_path in files:
41
+ try:
42
+ stat = file_path.stat()
43
+ except OSError:
44
+ # Deleted between the rglob listing and this stat() call -
45
+ # treat as simply absent from this signature, not an error.
46
+ continue
47
+ try:
48
+ relative = file_path.relative_to(path)
49
+ except ValueError:
50
+ relative = file_path
51
+ entries.append(f"{relative.as_posix()}:{stat.st_mtime_ns}:{stat.st_size}")
52
+
53
+ digest = hashlib.sha256("\n".join(sorted(entries)).encode("utf-8"))
54
+ return digest.hexdigest()
55
+
56
+
57
+ def get_git_head_sha(repo_path: Path, branch: str | None) -> str | None:
58
+ """Runs `git rev-parse {branch or 'HEAD'}` in repo_path. Returns None
59
+ (logged as a warning, not raised) if git isn't installed, repo_path
60
+ isn't a git repo, or branch doesn't exist - the daemon just skips that
61
+ job for this poll rather than marking it an error, since this is
62
+ typically a transient/config issue that resolves itself (e.g. the repo
63
+ hasn't been cloned yet) rather than a real job failure."""
64
+ ref = branch or "HEAD"
65
+ try:
66
+ result = subprocess.run(
67
+ ["git", "rev-parse", ref],
68
+ cwd=repo_path,
69
+ capture_output=True,
70
+ text=True,
71
+ timeout=10,
72
+ check=False,
73
+ )
74
+ except (OSError, subprocess.TimeoutExpired) as exc:
75
+ logger.warning("Couldn't run git in %s: %s", repo_path, exc)
76
+ return None
77
+
78
+ if result.returncode != 0:
79
+ logger.warning(
80
+ "git rev-parse %s failed in %s: %s", ref, repo_path, result.stderr.strip()
81
+ )
82
+ return None
83
+
84
+ return result.stdout.strip()
File without changes
pcli/session/audit.py ADDED
@@ -0,0 +1,122 @@
1
+ """Tamper-evident audit log: a hash-chained record of permission decisions
2
+ and tool calls, kept alongside - not instead of - Session.tool_invocations/
3
+ permission_grants. Opt-in (Settings.audit_mode_enabled), for regulated-
4
+ industry use where a user needs to prove, after the fact, what an
5
+ unattended agent actually did and that the record wasn't edited later.
6
+
7
+ The chain primitive is deliberately simple: each entry's hash covers its
8
+ own content plus the previous entry's hash, same as a git commit or a
9
+ certificate-transparency log. No new dependency (stdlib hashlib/json
10
+ only), no signing/external timestamping service - that would be real
11
+ complexity for marginal benefit over what a plain SHA-256 chain already
12
+ gives: editing, reordering, or deleting an old entry breaks the chain from
13
+ that point forward, and the break can't be silently repaired without
14
+ re-deriving every hash after it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ from dataclasses import dataclass
22
+ from datetime import UTC, datetime
23
+ from typing import TYPE_CHECKING, Any, Literal
24
+
25
+ from pydantic import BaseModel, Field
26
+
27
+ from pcli.util.ids import new_id
28
+
29
+ if TYPE_CHECKING:
30
+ from pcli.session.models import Session
31
+
32
+ _GENESIS_HASH = "0" * 64
33
+
34
+
35
+ def _utcnow() -> datetime:
36
+ return datetime.now(UTC)
37
+
38
+
39
+ class AuditEntry(BaseModel):
40
+ id: str = Field(default_factory=lambda: new_id("audit_"))
41
+ created_at: datetime = Field(default_factory=_utcnow)
42
+ kind: Literal["tool_call", "permission_decision"]
43
+ summary: str
44
+ """Human-readable one-liner, e.g. 'read_file allowed' or 'run_shell
45
+ denied: command matched denylist entry'."""
46
+ detail: dict[str, Any] = Field(default_factory=dict)
47
+ """kind-specific structured payload - see append_audit_entry's callers
48
+ (agent/runtime.py's record_tool_invocation, permissions/manager.py's
49
+ check_with_reason) for the exact fields each kind carries."""
50
+ prev_hash: str
51
+ """The previous entry's entry_hash, or _GENESIS_HASH for the first
52
+ entry in a session's chain."""
53
+ entry_hash: str
54
+ """sha256 over a canonical (sort_keys=True) JSON serialization of
55
+ every field above except this one - see _compute_hash."""
56
+
57
+
58
+ @dataclass
59
+ class AuditVerification:
60
+ valid: bool
61
+ entry_count: int
62
+ broken_at_index: int | None = None
63
+ reason: str | None = None
64
+
65
+
66
+ def _hashable_payload(entry: AuditEntry) -> dict[str, Any]:
67
+ return {
68
+ "id": entry.id,
69
+ "created_at": entry.created_at.isoformat(),
70
+ "kind": entry.kind,
71
+ "summary": entry.summary,
72
+ "detail": entry.detail,
73
+ "prev_hash": entry.prev_hash,
74
+ }
75
+
76
+
77
+ def _compute_hash(payload: dict[str, Any]) -> str:
78
+ canonical = json.dumps(payload, sort_keys=True, default=str)
79
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
80
+
81
+
82
+ def append_audit_entry(
83
+ session: Session, *, kind: Literal["tool_call", "permission_decision"], summary: str, detail: dict[str, Any]
84
+ ) -> AuditEntry:
85
+ """Appends one entry to session.audit_log, chained onto whatever's
86
+ already there. Callers gate this behind Settings.audit_mode_enabled
87
+ themselves (agent/runtime.py's record_tool_invocation, permissions/
88
+ manager.py's PermissionManager) - this function always appends
89
+ unconditionally when called, same as Session.decisions/
90
+ tool_invocations never gate themselves either."""
91
+ prev_hash = session.audit_log[-1].entry_hash if session.audit_log else _GENESIS_HASH
92
+ entry = AuditEntry(kind=kind, summary=summary, detail=detail, prev_hash=prev_hash, entry_hash="")
93
+ entry.entry_hash = _compute_hash(_hashable_payload(entry))
94
+ session.audit_log.append(entry)
95
+ return entry
96
+
97
+
98
+ def verify_audit_chain(session: Session) -> AuditVerification:
99
+ """Recomputes every entry's hash from its recorded fields and checks
100
+ prev_hash linkage - an empty log is valid (nothing to verify, not an
101
+ error: audit_mode_enabled may simply have been off for this session)."""
102
+ expected_prev = _GENESIS_HASH
103
+ for index, entry in enumerate(session.audit_log):
104
+ if entry.prev_hash != expected_prev:
105
+ return AuditVerification(
106
+ valid=False,
107
+ entry_count=len(session.audit_log),
108
+ broken_at_index=index,
109
+ reason="prev_hash does not match the preceding entry's hash - an entry was "
110
+ "likely reordered or deleted",
111
+ )
112
+ recomputed = _compute_hash(_hashable_payload(entry))
113
+ if recomputed != entry.entry_hash:
114
+ return AuditVerification(
115
+ valid=False,
116
+ entry_count=len(session.audit_log),
117
+ broken_at_index=index,
118
+ reason="entry_hash does not match the recomputed hash - this entry's content "
119
+ "was modified after it was recorded",
120
+ )
121
+ expected_prev = entry.entry_hash
122
+ return AuditVerification(valid=True, entry_count=len(session.audit_log))
@@ -0,0 +1,28 @@
1
+ """Warns (never blocks) when a session is resumed from a different directory
2
+ than where it was created - every relative path in its tool-call history
3
+ resolves against that original directory, so a mismatch is worth surfacing
4
+ before continuing, not silently ignoring. Deliberately its own tiny module
5
+ (not a method on Session) so both the TUI's session picker and the CLI's
6
+ --resume flag can share the exact same check without depending on Textual
7
+ or Typer."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from pcli.session.models import Session
14
+
15
+
16
+ def directory_mismatch(session: Session, cwd: Path) -> str | None:
17
+ """None if there's nothing to warn about (no recorded working_dir, or it
18
+ matches cwd); otherwise a human-readable warning message."""
19
+ if not session.working_dir:
20
+ return None
21
+ stored = Path(session.working_dir)
22
+ if stored.expanduser().resolve() == cwd.expanduser().resolve():
23
+ return None
24
+ return (
25
+ f"This session was last run in {session.working_dir}, but you're currently in "
26
+ f"{cwd}. Relative paths in its history (and any new tool calls) will resolve "
27
+ "against the current directory, not the original one."
28
+ )
pcli/session/export.py ADDED
@@ -0,0 +1,57 @@
1
+ """Exports a session to a portable, self-contained .pcli-session.json[.gz] file.
2
+
3
+ The gateway API key is never part of the Session model in the first place, so
4
+ there is nothing to redact there. `permission_grants` travels with the export
5
+ for fidelity, but importer.py drops it by default (see importer.py docstring)
6
+ so an imported session can't silently grant shell access on the new machine.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import gzip
12
+ import json
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+
16
+ from pcli import __version__
17
+ from pcli.session.models import Session
18
+ from pcli.session.store import SessionStore
19
+
20
+ FORMAT_NAME = "pcli-session"
21
+ FORMAT_VERSION = 1
22
+
23
+
24
+ def export_session(
25
+ session: Session,
26
+ out_path: Path,
27
+ *,
28
+ store: SessionStore | None = None,
29
+ use_gzip: bool | None = None,
30
+ ) -> Path:
31
+ store = store or SessionStore()
32
+ if use_gzip is None:
33
+ use_gzip = out_path.suffix == ".gz"
34
+
35
+ blobs: dict[str, str] = {}
36
+ for invocation in session.tool_invocations:
37
+ if invocation.full_result_ref:
38
+ blobs[invocation.full_result_ref] = store.read_blob(
39
+ session.id, invocation.full_result_ref
40
+ )
41
+
42
+ envelope = {
43
+ "format": FORMAT_NAME,
44
+ "format_version": FORMAT_VERSION,
45
+ "exported_at": datetime.now(UTC).isoformat(),
46
+ "pcli_version": __version__,
47
+ "session": json.loads(session.model_dump_json()),
48
+ "blobs": blobs,
49
+ }
50
+
51
+ body = json.dumps(envelope, indent=2).encode("utf-8")
52
+ out_path.parent.mkdir(parents=True, exist_ok=True)
53
+ if use_gzip:
54
+ out_path.write_bytes(gzip.compress(body))
55
+ else:
56
+ out_path.write_bytes(body)
57
+ return out_path
@@ -0,0 +1,92 @@
1
+ """Imports a session exported by session/export.py.
2
+
3
+ Three deliberate safety choices:
4
+ - `permission_grants` is dropped unless `restore_grants=True` is passed
5
+ explicitly, so an imported session can't silently carry "always allow
6
+ shell" grants onto a new machine.
7
+ - The session always gets a *new* id on import (the original id is kept in
8
+ metadata for traceability) so importing never collides with / overwrites
9
+ an existing local session.
10
+ - `working_dir` is always cleared: it names a directory on whatever machine
11
+ originally created the session, essentially never the importing one, so
12
+ keeping it would just make every resume trip the directory-mismatch
13
+ warning (session/directory_check.py) for no reason - export/import is a
14
+ deliberate move to a new place, not an accidental one.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import gzip
20
+ import json
21
+ from collections.abc import Callable
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from pcli.session.export import FORMAT_NAME, FORMAT_VERSION
26
+ from pcli.session.models import Session
27
+ from pcli.session.store import SessionStore
28
+
29
+
30
+ class SessionImportError(Exception):
31
+ pass
32
+
33
+
34
+ # Each entry migrates a raw session dict from its key version to key+1.
35
+ # e.g. {1: _migrate_v1_to_v2} once format_version 2 exists.
36
+ _MIGRATIONS: dict[int, Callable[[dict[str, Any]], dict[str, Any]]] = {}
37
+
38
+
39
+ def _read_envelope(path: Path) -> dict[str, Any]:
40
+ raw = path.read_bytes()
41
+ if raw[:2] == b"\x1f\x8b": # gzip magic bytes
42
+ raw = gzip.decompress(raw)
43
+ return json.loads(raw.decode("utf-8"))
44
+
45
+
46
+ def import_session(
47
+ path: Path,
48
+ *,
49
+ store: SessionStore | None = None,
50
+ restore_grants: bool = False,
51
+ ) -> Session:
52
+ store = store or SessionStore()
53
+ envelope = _read_envelope(path)
54
+
55
+ if envelope.get("format") != FORMAT_NAME:
56
+ raise SessionImportError(f"Not a pcli session export: {path}")
57
+
58
+ version = envelope.get("format_version")
59
+ if not isinstance(version, int):
60
+ raise SessionImportError(f"Missing/invalid format_version in {path}")
61
+ if version > FORMAT_VERSION:
62
+ raise SessionImportError(
63
+ f"{path} was exported by a newer pcli (format_version={version}); "
64
+ f"this pcli supports up to {FORMAT_VERSION}. Upgrade pcli to import it."
65
+ )
66
+
67
+ session_data = envelope["session"]
68
+ while version < FORMAT_VERSION:
69
+ migrate = _MIGRATIONS.get(version)
70
+ if migrate is None:
71
+ raise SessionImportError(f"No migration registered from format_version {version}")
72
+ session_data = migrate(session_data)
73
+ version += 1
74
+
75
+ original_id = session_data.pop("id", None)
76
+ if not restore_grants:
77
+ session_data["permission_grants"] = []
78
+ session_data["working_dir"] = None
79
+
80
+ session = Session.model_validate(session_data)
81
+ session.metadata["imported_from_id"] = original_id
82
+ session.metadata["imported_from_file"] = str(path)
83
+
84
+ blobs: dict[str, str] = envelope.get("blobs", {})
85
+ for invocation in session.tool_invocations:
86
+ if invocation.full_result_ref and invocation.full_result_ref in blobs:
87
+ store.write_blob_named(
88
+ session.id, invocation.full_result_ref, blobs[invocation.full_result_ref]
89
+ )
90
+
91
+ store.save(session)
92
+ return session