pocketshell 0.3.33__tar.gz → 0.3.34__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 (51) hide show
  1. {pocketshell-0.3.33 → pocketshell-0.3.34}/PKG-INFO +4 -1
  2. {pocketshell-0.3.33 → pocketshell-0.3.34}/pyproject.toml +19 -1
  3. pocketshell-0.3.34/src/pocketshell/agents.py +519 -0
  4. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/cli.py +6 -0
  5. pocketshell-0.3.34/src/pocketshell/profiles.py +425 -0
  6. pocketshell-0.3.34/src/pocketshell/push.py +465 -0
  7. pocketshell-0.3.34/src/pocketshell/resume.py +698 -0
  8. pocketshell-0.3.34/src/pocketshell/sessions.py +449 -0
  9. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/usage_capture.py +7 -0
  10. pocketshell-0.3.34/tests/test_agents.py +630 -0
  11. pocketshell-0.3.34/tests/test_profiles.py +305 -0
  12. pocketshell-0.3.34/tests/test_push.py +322 -0
  13. pocketshell-0.3.34/tests/test_resume.py +558 -0
  14. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_sessions.py +118 -0
  15. pocketshell-0.3.34/uv.lock +559 -0
  16. pocketshell-0.3.33/src/pocketshell/sessions.py +0 -210
  17. pocketshell-0.3.33/uv.lock +0 -247
  18. {pocketshell-0.3.33 → pocketshell-0.3.34}/.gitignore +0 -0
  19. {pocketshell-0.3.33 → pocketshell-0.3.34}/README.md +0 -0
  20. {pocketshell-0.3.33 → pocketshell-0.3.34}/scheduler/README.md +0 -0
  21. {pocketshell-0.3.33 → pocketshell-0.3.34}/scheduler/pocketshell-usage-capture.service +0 -0
  22. {pocketshell-0.3.33 → pocketshell-0.3.34}/scheduler/pocketshell-usage-capture.timer +0 -0
  23. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/__init__.py +0 -0
  24. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/__main__.py +0 -0
  25. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/agent_log.py +0 -0
  26. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/daemon.py +0 -0
  27. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/env.py +0 -0
  28. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/github.py +0 -0
  29. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/hooks.py +0 -0
  30. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/jobs.py +0 -0
  31. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/logs.py +0 -0
  32. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/prune_attachments.py +0 -0
  33. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/qr_share.py +0 -0
  34. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/repos.py +0 -0
  35. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/usage.py +0 -0
  36. {pocketshell-0.3.33 → pocketshell-0.3.34}/src/pocketshell/usage_reset.py +0 -0
  37. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/__init__.py +0 -0
  38. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_agent_log.py +0 -0
  39. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_cli.py +0 -0
  40. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_daemon.py +0 -0
  41. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_env.py +0 -0
  42. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_github.py +0 -0
  43. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_hooks.py +0 -0
  44. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_jobs.py +0 -0
  45. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_logs.py +0 -0
  46. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_prune_attachments.py +0 -0
  47. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_qr_share.py +0 -0
  48. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_repos.py +0 -0
  49. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_usage.py +0 -0
  50. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_usage_capture.py +0 -0
  51. {pocketshell-0.3.33 → pocketshell-0.3.34}/tests/test_usage_reset.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.3.33
3
+ Version: 0.3.34
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
@@ -19,6 +19,9 @@ Classifier: Topic :: Software Development
19
19
  Classifier: Topic :: System :: Monitoring
20
20
  Requires-Python: >=3.11
21
21
  Requires-Dist: click>=8.2.0
22
+ Requires-Dist: google-auth>=2.0.0
23
+ Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: tmuxctl>=0.3.3
22
25
  Provides-Extra: dev
23
26
  Requires-Dist: pytest>=8.4.0; extra == 'dev'
24
27
  Requires-Dist: ruff>=0.15.0; extra == 'dev'
@@ -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.3.33"
11
+ version = "0.3.34"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -33,6 +33,24 @@ classifiers = [
33
33
  # in directly later. Pin lower-bound only.
34
34
  dependencies = [
35
35
  "click>=8.2.0",
36
+ # FCM HTTP v1 push delivery (#690): service-account OAuth2 bearer minting
37
+ # for `pocketshell push` / the `usage --capture` reset-push send. Imported
38
+ # lazily and fail-soft — a host without it (or without a Firebase
39
+ # credential) simply no-ops the send, so the lower bound is advisory.
40
+ "google-auth>=2.0.0",
41
+ # YAML serialization for `pocketshell profiles list` (#718) — the host's
42
+ # human/agent-readable default output format (#714). The Android client
43
+ # consumes the `--json` flag instead; the host-facing default is YAML.
44
+ "pyyaml>=6.0",
45
+ # Session create/resume delegates to the `tmuxctl` binary (see
46
+ # `pocketshell/resume.py`: `tmuxctl create-detached` / `create-or-attach`),
47
+ # so installing pocketshell pulls a tmuxctl that has the server-cgroup fix.
48
+ # >=0.3.3 is required: earlier tmuxctl started the shared tmux server inside
49
+ # the SSH login's `session-*.scope`, so a logout (or a per-session OOM) tore
50
+ # down every session at once; 0.3.3 starts the server in its own
51
+ # login-independent unit under robust.slice (tmuxctl#4). 0.3.0 first shipped
52
+ # the `create-detached` verb this CLI calls.
53
+ "tmuxctl>=0.3.3",
36
54
  ]
37
55
 
38
56
  [project.scripts]
@@ -0,0 +1,519 @@
1
+ """`pocketshell agent <kind> --dir <dir>` subcommand.
2
+
3
+ Launch a coding-agent CLI (``codex`` / ``claude`` / ``opencode``) in a
4
+ folder, server-side, replacing the giant inline ``env -u VAR1 -u VAR2 …``
5
+ line the Android app used to type into the new tmux pane (issue #703).
6
+
7
+ Why this exists
8
+ ---------------
9
+
10
+ The app previously reconstructed the *entire* launch chain inline:
11
+
12
+ ```
13
+ eval "$(pocketshell env export --dir '<dir>')"; env -u VAR1 … (71 vars) … codex --dangerously-bypass-approvals-and-sandbox
14
+ ```
15
+
16
+ That is ~1500 characters of brittle shell typed into the pane. Worse, the
17
+ agent then **parked on a first-run modal prompt** the user never knew to
18
+ dismiss:
19
+
20
+ - ``codex 0.137.0`` halts on *"Update available 0.137.0 → 0.139.0 — Press
21
+ enter to continue"*.
22
+ - ``claude`` in a fresh folder halts on *"Is this a project you trust?
23
+ 1. Yes / 2. No"*.
24
+
25
+ So the agent *appeared* but never actually became usable. This wrapper
26
+ replaces the whole inline chain with one short line —
27
+ ``pocketshell agent <kind> --dir <dir> [--skip-permissions]
28
+ [--config-dir <dir>]`` — and **suppresses those first-run prompts** so the
29
+ agent UI is immediately usable.
30
+
31
+ What it does
32
+ ------------
33
+
34
+ 1. ``cd <dir>`` (validated, like ``env``'s ``_resolve_dir``).
35
+ 2. Merge the folder's ``.env`` / ``.envrc`` into the environment
36
+ (reuses :func:`pocketshell.env.merged_exports`) — this replaces the
37
+ ``eval "$(pocketshell env export …)"`` prelude.
38
+ 3. Apply the env-strip **for every agent kind** (see below).
39
+ 4. Suppress the agent's first-run prompt.
40
+ 5. ``os.execvpe`` the agent so it replaces the wrapper process and owns the
41
+ pty cleanly.
42
+
43
+ Env-strip scope (issue #703 — maintainer decision: ALL three agents)
44
+ --------------------------------------------------------------------
45
+
46
+ Maintainer decision (2026-06-11, issue #703): strip the provider API-key
47
+ vars for **all three** agents — ``codex``, ``claude``, and ``opencode`` —
48
+ so each falls back to its *subscription* auth instead of a per-token env
49
+ API key (which bills per token). Subscription billing across the board.
50
+
51
+ This matches the old app behaviour (which stripped for all three) but now
52
+ lives in the concise ``pocketshell agent`` wrapper instead of being
53
+ reconstructed inline by the app. The 71-var list is
54
+ :data:`PROVIDER_ENV_UNSET_VARS`.
55
+
56
+ Prompt suppression (the part that fixes "the agent doesn't start")
57
+ ------------------------------------------------------------------
58
+
59
+ - **codex** — ``-c check_for_update_on_startup=false`` disables the
60
+ startup update check, so codex never parks on the
61
+ "Update available … Press enter to continue" modal. The project-trust
62
+ prompt does not appear in codex 0.137.0 (verified), so no extra trust
63
+ seeding is needed.
64
+ - **claude** — the workspace-trust dialog is gated by
65
+ ``hasTrustDialogAccepted`` per project in ``~/.claude.json``. Even
66
+ ``--dangerously-skip-permissions`` does NOT skip it (issue #703). The
67
+ wrapper pre-seeds ``projects.<dir>.hasTrustDialogAccepted = true`` before
68
+ exec, so claude starts straight into the usable agent prompt.
69
+ - **opencode** — config-driven; no first-run modal to suppress.
70
+ """
71
+
72
+ from __future__ import annotations
73
+
74
+ import json
75
+ import os
76
+ from pathlib import Path
77
+ from typing import Optional
78
+
79
+ import click
80
+
81
+ from pocketshell.env import merged_exports
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Provider API-key env vars stripped for EVERY agent (subscription billing).
86
+ # ---------------------------------------------------------------------------
87
+ #
88
+ # CANONICAL SOURCE: the maintainer's dotfiles at
89
+ # ``config/opencode/env_unset.txt`` (installed as
90
+ # ``~/git/.claude/config/opencode/env_unset.txt``). This list is a verbatim
91
+ # copy of that file (71 entries). With these unset, an agent falls back to
92
+ # the maintainer's *subscription* auth instead of a per-token env API key
93
+ # (which bills per token). Keeping the list here makes the wrapper
94
+ # self-contained — it does not require the ``oc`` function or
95
+ # ``env_unset.txt`` to be present on the host.
96
+ #
97
+ # Maintainer decision (issue #703): strip these for ALL three agents
98
+ # (codex / claude / opencode), not opencode-only — subscription billing
99
+ # across the board.
100
+ #
101
+ # The Android picker (SessionTypePickerSheet.kt) used to carry an identical
102
+ # copy; with the wrapper owning the launch, the app no longer needs it.
103
+ PROVIDER_ENV_UNSET_VARS: tuple[str, ...] = (
104
+ "AWS_ACCESS_KEY_ID",
105
+ "AWS_SECRET_ACCESS_KEY",
106
+ "AWS_SESSION_TOKEN",
107
+ "AWS_PROFILE",
108
+ "AWS_REGION",
109
+ "AWS_BEARER_TOKEN_BEDROCK",
110
+ "AWS_WEB_IDENTITY_TOKEN_FILE",
111
+ "AWS_ROLE_ARN",
112
+ "OPENAI_API_KEY",
113
+ "OPENAI_BASE_URL",
114
+ "OPENAI_ORG_ID",
115
+ "OPENAI_PROJECT_ID",
116
+ "ANTHROPIC_API_KEY",
117
+ "ANTHROPIC_BASE_URL",
118
+ "ANTHROPIC_AUTH_TOKEN",
119
+ "GROQ_API_KEY",
120
+ "GOOGLE_APPLICATION_CREDENTIALS",
121
+ "GOOGLE_CLOUD_PROJECT",
122
+ "GOOGLE_API_KEY",
123
+ "VERTEX_LOCATION",
124
+ "VERTEX_AI_PROJECT",
125
+ "DEEPSEEK_API_KEY",
126
+ "XAI_API_KEY",
127
+ "FIREWORKS_API_KEY",
128
+ "CEREBRAS_API_KEY",
129
+ "OPENROUTER_API_KEY",
130
+ "TOGETHER_API_KEY",
131
+ "TOGETHER_AI_API_KEY",
132
+ "AZURE_API_KEY",
133
+ "AZURE_RESOURCE_NAME",
134
+ "AZURE_COGNITIVE_SERVICES_RESOURCE_NAME",
135
+ "AZURE_OPENAI_API_KEY",
136
+ "AZURE_OPENAI_ENDPOINT",
137
+ "CLOUDFLARE_API_TOKEN",
138
+ "CLOUDFLARE_ACCOUNT_ID",
139
+ "CLOUDFLARE_GATEWAY_ID",
140
+ "CLOUDFLARE_API_KEY",
141
+ "HUGGING_FACE_API_KEY",
142
+ "HF_TOKEN",
143
+ "HF_API_TOKEN",
144
+ "MOONSHOT_API_KEY",
145
+ "MOONSHOTAI_API_KEY",
146
+ "MINIMAX_API_KEY",
147
+ "NEBIUS_API_KEY",
148
+ "DEEPINFRA_API_KEY",
149
+ "BASETEN_API_KEY",
150
+ "VENICE_API_KEY",
151
+ "SCALEWAY_API_KEY",
152
+ "OVH_API_KEY",
153
+ "CORTECS_API_KEY",
154
+ "IONET_API_KEY",
155
+ "VERCEL_API_KEY",
156
+ "ZENMUX_API_KEY",
157
+ "ZAI_API_KEY",
158
+ "HELICONE_API_KEY",
159
+ "OPENCODE_API_KEY",
160
+ "OPENCODE_ZEN_API_KEY",
161
+ "GITLAB_TOKEN",
162
+ "GITLAB_INSTANCE_URL",
163
+ "GITLAB_AI_GATEWAY_URL",
164
+ "GITLAB_OAUTH_CLIENT_ID",
165
+ "AICORE_SERVICE_KEY",
166
+ "AICORE_DEPLOYMENT_ID",
167
+ "AICORE_RESOURCE_GROUP",
168
+ "OPENAI_COMPATIBLE_API_KEY",
169
+ "LMSTUDIO_API_KEY",
170
+ "OLLAMA_API_KEY",
171
+ "302AI_API_KEY",
172
+ "FIRMWARE_API_KEY",
173
+ "2AI_API_KEY",
174
+ "GEMINI_API_KEY",
175
+ )
176
+
177
+ # Recognised agent kinds. Order is the picker's order (claude, codex,
178
+ # opencode) but the wrapper is keyed by name, not ordinal.
179
+ AGENT_KINDS: tuple[str, ...] = ("codex", "claude", "opencode")
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Pure helpers (unit-tested without exec)
184
+ # ---------------------------------------------------------------------------
185
+
186
+
187
+ def build_env(
188
+ kind: str,
189
+ base_env: dict[str, str],
190
+ folder_exports: dict[str, str],
191
+ *,
192
+ config_dir: Optional[str] = None,
193
+ extra_env: Optional[dict[str, str]] = None,
194
+ ) -> dict[str, str]:
195
+ """Return the environment to exec the agent with.
196
+
197
+ Starts from ``base_env`` (normally ``os.environ``), layers the
198
+ folder's merged ``.env`` / ``.envrc`` exports on top, then the profile's
199
+ ``extra_env`` (from ``profiles.yaml``'s ``env:`` block, issue #718/#732),
200
+ then:
201
+
202
+ - For **every agent kind** (codex / claude / opencode), removes every
203
+ var in :data:`PROVIDER_ENV_UNSET_VARS` so the agent uses its
204
+ subscription auth instead of a per-token env API key (maintainer
205
+ decision, issue #703 — subscription billing across the board). This
206
+ strip runs **last** among the env layers, so even a provider key that
207
+ a profile's ``extra_env`` tries to inject is still stripped — the
208
+ #703 subscription-billing guarantee always wins over profile env.
209
+ - When ``config_dir`` is given, sets the agent's config-dir env var
210
+ (``CODEX_HOME`` for codex, ``CLAUDE_CONFIG_DIR`` for claude). Ignored
211
+ for opencode (no profile env var).
212
+ """
213
+ env = dict(base_env)
214
+ env.update(folder_exports)
215
+
216
+ # A profile's `env:` block (profiles.yaml) layers on top of the folder
217
+ # exports (issue #732). It is applied BEFORE the provider strip below so
218
+ # the strip still wins for provider keys — a profile can set arbitrary
219
+ # non-provider vars, but cannot re-inject a stripped API key.
220
+ if extra_env:
221
+ env.update(extra_env)
222
+
223
+ # Strip the provider API-key vars for EVERY agent kind so each falls
224
+ # back to its subscription auth (maintainer decision, issue #703 —
225
+ # subscription billing across the board for codex / claude / opencode).
226
+ # Runs last so it overrides any provider key from base/folder/profile env.
227
+ for name in PROVIDER_ENV_UNSET_VARS:
228
+ env.pop(name, None)
229
+
230
+ if config_dir:
231
+ if kind == "codex":
232
+ env["CODEX_HOME"] = config_dir
233
+ elif kind == "claude":
234
+ env["CLAUDE_CONFIG_DIR"] = config_dir
235
+
236
+ return env
237
+
238
+
239
+ def build_argv(kind: str, *, skip_permissions: bool) -> list[str]:
240
+ """Return the argv (program + args) used to exec the agent.
241
+
242
+ The argv carries the per-agent first-run-prompt suppression and the
243
+ skip-permissions flag:
244
+
245
+ - **codex** — ``-c check_for_update_on_startup=false`` suppresses the
246
+ startup update-check modal (issue #703).
247
+ ``--dangerously-bypass-approvals-and-sandbox`` when
248
+ ``skip_permissions`` (the maintainer's ``cy`` alias).
249
+ - **claude** — ``--dangerously-skip-permissions`` when
250
+ ``skip_permissions`` (the ``csp`` alias). The trust dialog is
251
+ suppressed out-of-band by pre-seeding ``~/.claude.json`` (see
252
+ :func:`seed_claude_trust`), not via argv.
253
+ - **opencode** — no skip flag (permissions are config-driven in
254
+ ``opencode.json``); the billing fix is the env strip, not a flag.
255
+ """
256
+ if kind == "codex":
257
+ argv = ["codex", "-c", "check_for_update_on_startup=false"]
258
+ if skip_permissions:
259
+ argv.append("--dangerously-bypass-approvals-and-sandbox")
260
+ return argv
261
+ if kind == "claude":
262
+ argv = ["claude"]
263
+ if skip_permissions:
264
+ argv.append("--dangerously-skip-permissions")
265
+ return argv
266
+ if kind == "opencode":
267
+ return ["opencode"]
268
+ raise ValueError(f"unknown agent kind: {kind!r}")
269
+
270
+
271
+ def claude_config_path(env: dict[str, str]) -> Path:
272
+ """Return the ``~/.claude.json`` path claude reads its trust state from.
273
+
274
+ Honours ``CLAUDE_CONFIG_DIR`` (set when a non-default profile is
275
+ selected) — claude stores ``.claude.json`` inside that dir; otherwise
276
+ it lives at ``$HOME/.claude.json``.
277
+ """
278
+ config_dir = env.get("CLAUDE_CONFIG_DIR")
279
+ if config_dir:
280
+ return Path(config_dir).expanduser() / ".claude.json"
281
+ home = env.get("HOME") or os.path.expanduser("~")
282
+ return Path(home) / ".claude.json"
283
+
284
+
285
+ def seed_claude_trust(config_path: Path, directory: str) -> None:
286
+ """Pre-accept claude's workspace-trust dialog for ``directory``.
287
+
288
+ claude gates the *"Is this a project you trust?"* modal on
289
+ ``projects.<dir>.hasTrustDialogAccepted`` in ``~/.claude.json``. Even
290
+ ``--dangerously-skip-permissions`` does NOT skip it (issue #703), so
291
+ the wrapper seeds the flag before exec.
292
+
293
+ Best-effort and non-destructive: it reads the existing config (an
294
+ object), sets only the one nested flag, and writes it back. Any I/O or
295
+ parse error is swallowed — a missing/corrupt config simply means
296
+ claude shows its own trust prompt, the pre-existing behaviour, so the
297
+ wrapper never makes the launch *worse* by failing here.
298
+ """
299
+ try:
300
+ if config_path.exists():
301
+ data = json.loads(config_path.read_text(encoding="utf-8"))
302
+ if not isinstance(data, dict):
303
+ return
304
+ else:
305
+ data = {}
306
+ projects = data.setdefault("projects", {})
307
+ if not isinstance(projects, dict):
308
+ return
309
+ entry = projects.setdefault(directory, {})
310
+ if not isinstance(entry, dict):
311
+ return
312
+ if entry.get("hasTrustDialogAccepted") is True:
313
+ return # already trusted; nothing to write
314
+ entry["hasTrustDialogAccepted"] = True
315
+ config_path.parent.mkdir(parents=True, exist_ok=True)
316
+ config_path.write_text(
317
+ json.dumps(data, ensure_ascii=False), encoding="utf-8"
318
+ )
319
+ except (OSError, ValueError):
320
+ # Trust seeding is best-effort; a failure here only means claude
321
+ # shows its own prompt (the old behaviour), never a broken launch.
322
+ return
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # Click surface
327
+ # ---------------------------------------------------------------------------
328
+
329
+
330
+ def _resolve_dir(ctx: click.Context, directory: str) -> Path:
331
+ """Expand ``directory`` and require it to be an existing folder."""
332
+ path = Path(os.path.expanduser(directory))
333
+ if not path.is_dir():
334
+ click.echo(
335
+ f"pocketshell agent: directory does not exist: {path}", err=True
336
+ )
337
+ ctx.exit(2)
338
+ return path
339
+
340
+
341
+ def launch_agent(
342
+ ctx: click.Context,
343
+ kind: str,
344
+ directory: str,
345
+ *,
346
+ skip_permissions: bool,
347
+ config_dir: Optional[str],
348
+ extra_env: Optional[dict[str, str]] = None,
349
+ execvpe=None,
350
+ ) -> None:
351
+ """Resolve the dir, build env+argv, suppress prompts, exec the agent.
352
+
353
+ ``extra_env`` carries the selected profile's ``env:`` block (issue
354
+ #732); it layers onto the launch environment under the #703 provider
355
+ strip (see :func:`build_env`).
356
+
357
+ ``execvpe`` is injected so tests can assert the exact call without
358
+ actually replacing the process. When ``None`` (production) it resolves
359
+ to :func:`os.execvpe` *at call time* — looking it up on the module's
360
+ ``os`` so a monkeypatch on ``agents.os.execvpe`` is honoured (a default
361
+ argument would bind the original at def-time and bypass the patch).
362
+ :func:`os.execvpe` never returns on success.
363
+ """
364
+ if execvpe is None:
365
+ execvpe = os.execvpe
366
+
367
+ path = _resolve_dir(ctx, directory)
368
+ resolved_dir = str(path)
369
+
370
+ folder_exports = merged_exports(path)
371
+ env = build_env(
372
+ kind,
373
+ dict(os.environ),
374
+ folder_exports,
375
+ config_dir=config_dir,
376
+ extra_env=extra_env,
377
+ )
378
+ argv = build_argv(kind, skip_permissions=skip_permissions)
379
+
380
+ # Run from the folder so the agent's cwd is correct.
381
+ os.chdir(resolved_dir)
382
+
383
+ if kind == "claude":
384
+ seed_claude_trust(claude_config_path(env), resolved_dir)
385
+
386
+ # Replace this process with the agent so it owns the pty cleanly.
387
+ execvpe(argv[0], argv, env)
388
+
389
+
390
+ def _resolve_config_dir(
391
+ ctx: click.Context,
392
+ kind: str,
393
+ config_dir: Optional[str],
394
+ profile: Optional[str],
395
+ ) -> tuple[Optional[str], dict[str, str]]:
396
+ """Resolve config dir + extra env from ``--config-dir`` / ``--profile``.
397
+
398
+ Returns ``(config_dir, extra_env)``. ``--config-dir`` and ``--profile``
399
+ are mutually exclusive (passing both is an error). When ``--profile`` is
400
+ given, it resolves the named host profile (via
401
+ :func:`pocketshell.profiles.resolve_profile`) to its ``config_dir`` AND
402
+ its ``env:`` block (issue #732) — an unknown profile is a clear error. A
403
+ default profile resolves to ``None`` config dir (the engine's built-in
404
+ location); ``--config-dir`` carries no profile env. Omitting both flags
405
+ returns ``(None, {})``.
406
+ """
407
+ if config_dir is not None and profile is not None:
408
+ click.echo(
409
+ "pocketshell agent: --config-dir and --profile are mutually "
410
+ "exclusive",
411
+ err=True,
412
+ )
413
+ ctx.exit(2)
414
+ if profile is None:
415
+ return config_dir, {}
416
+
417
+ # Lazy import keeps the agent launch path from importing yaml unless a
418
+ # profile is actually requested.
419
+ from pocketshell.profiles import resolve_profile
420
+
421
+ try:
422
+ resolved = resolve_profile(profile, kind)
423
+ except KeyError:
424
+ click.echo(
425
+ f"pocketshell agent: unknown {kind} profile: {profile!r} "
426
+ f"(see `pocketshell profiles list --engine {kind}`)",
427
+ err=True,
428
+ )
429
+ ctx.exit(2)
430
+ return resolved.config_dir, dict(resolved.env)
431
+
432
+
433
+ def _make_agent_command(kind: str):
434
+ """Build the Click command for one agent kind."""
435
+
436
+ @click.command(
437
+ name=kind,
438
+ context_settings={"help_option_names": ["-h", "--help"]},
439
+ help=f"Launch `{kind}` in --dir with first-run prompts suppressed.",
440
+ )
441
+ @click.option(
442
+ "--dir",
443
+ "directory",
444
+ required=True,
445
+ type=str,
446
+ help="Folder to launch the agent in (its cwd).",
447
+ )
448
+ @click.option(
449
+ "--skip-permissions/--no-skip-permissions",
450
+ default=True,
451
+ show_default=True,
452
+ help=(
453
+ "Launch with per-action approval prompts disabled "
454
+ "(codex YOLO / claude bypass). No-op for opencode."
455
+ ),
456
+ )
457
+ @click.option(
458
+ "--config-dir",
459
+ "config_dir",
460
+ default=None,
461
+ type=str,
462
+ help=(
463
+ "Profile config dir: CODEX_HOME (codex) / CLAUDE_CONFIG_DIR "
464
+ "(claude). Ignored for opencode. Mutually exclusive with "
465
+ "--profile."
466
+ ),
467
+ )
468
+ @click.option(
469
+ "--profile",
470
+ "profile",
471
+ default=None,
472
+ type=str,
473
+ help=(
474
+ "Named host profile (see `pocketshell profiles list`); resolves "
475
+ "to its config dir. Mutually exclusive with --config-dir."
476
+ ),
477
+ )
478
+ @click.pass_context
479
+ def _cmd(
480
+ ctx: click.Context,
481
+ directory: str,
482
+ skip_permissions: bool,
483
+ config_dir: Optional[str],
484
+ profile: Optional[str],
485
+ ) -> None:
486
+ config_dir, extra_env = _resolve_config_dir(
487
+ ctx, kind, config_dir, profile
488
+ )
489
+ launch_agent(
490
+ ctx,
491
+ kind,
492
+ directory,
493
+ skip_permissions=skip_permissions,
494
+ config_dir=config_dir,
495
+ extra_env=extra_env,
496
+ )
497
+
498
+ return _cmd
499
+
500
+
501
+ @click.group(
502
+ name="agent",
503
+ context_settings={"help_option_names": ["-h", "--help"]},
504
+ help=(
505
+ "Launch a coding-agent CLI in a folder, server-side.\n\n"
506
+ "Replaces the giant inline `env -u … <agent>` line the app used to "
507
+ "type into the pane. Merges the folder's `.env`/`.envrc`, strips "
508
+ "provider API-key vars for every agent (subscription billing), and "
509
+ "suppresses each agent's first-run modal (codex update check / "
510
+ "claude folder-trust) so the agent is immediately usable. "
511
+ "See issue #703."
512
+ ),
513
+ )
514
+ def agent_group() -> None:
515
+ """Top-level group registered onto the root `pocketshell` CLI."""
516
+
517
+
518
+ for _kind in AGENT_KINDS:
519
+ agent_group.add_command(_make_agent_command(_kind))
@@ -23,12 +23,15 @@ import click
23
23
 
24
24
  from pocketshell import __version__
25
25
  from pocketshell.agent_log import agent_log_command
26
+ from pocketshell.agents import agent_group
26
27
  from pocketshell.env import env_group
27
28
  from pocketshell.github import github_group
28
29
  from pocketshell.hooks import hooks_group
29
30
  from pocketshell.jobs import jobs_group
30
31
  from pocketshell.logs import logs_group
32
+ from pocketshell.profiles import profiles_group
31
33
  from pocketshell.prune_attachments import prune_attachments_command
34
+ from pocketshell.push import push_group
32
35
  from pocketshell.qr_share import qr_share_command
33
36
  from pocketshell.repos import repos_group
34
37
  from pocketshell.sessions import sessions_group
@@ -51,6 +54,8 @@ def cli() -> None:
51
54
 
52
55
 
53
56
  cli.add_command(usage_command, name="usage")
57
+ cli.add_command(agent_group, name="agent")
58
+ cli.add_command(profiles_group, name="profiles")
54
59
  cli.add_command(jobs_group, name="jobs")
55
60
  cli.add_command(sessions_group, name="sessions")
56
61
  cli.add_command(agent_log_command, name="agent-log")
@@ -60,6 +65,7 @@ cli.add_command(env_group, name="env")
60
65
  cli.add_command(hooks_group, name="hooks")
61
66
  cli.add_command(logs_group, name="logs")
62
67
  cli.add_command(prune_attachments_command, name="prune-attachments")
68
+ cli.add_command(push_group, name="push")
63
69
  cli.add_command(qr_share_command, name="qr-share")
64
70
 
65
71