pocketshell 0.3.33__tar.gz → 0.4.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.
- {pocketshell-0.3.33 → pocketshell-0.4.0}/PKG-INFO +4 -1
- {pocketshell-0.3.33 → pocketshell-0.4.0}/pyproject.toml +19 -1
- pocketshell-0.4.0/src/pocketshell/agents.py +519 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/cli.py +6 -0
- pocketshell-0.4.0/src/pocketshell/profiles.py +425 -0
- pocketshell-0.4.0/src/pocketshell/push.py +465 -0
- pocketshell-0.4.0/src/pocketshell/resume.py +698 -0
- pocketshell-0.4.0/src/pocketshell/sessions.py +449 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/usage_capture.py +7 -0
- pocketshell-0.4.0/tests/test_agents.py +630 -0
- pocketshell-0.4.0/tests/test_profiles.py +305 -0
- pocketshell-0.4.0/tests/test_push.py +322 -0
- pocketshell-0.4.0/tests/test_resume.py +558 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/test_sessions.py +118 -0
- pocketshell-0.4.0/uv.lock +559 -0
- pocketshell-0.3.33/src/pocketshell/sessions.py +0 -210
- pocketshell-0.3.33/uv.lock +0 -247
- {pocketshell-0.3.33 → pocketshell-0.4.0}/.gitignore +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/README.md +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/scheduler/README.md +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/scheduler/pocketshell-usage-capture.service +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/scheduler/pocketshell-usage-capture.timer +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/agent_log.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/daemon.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/env.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/github.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/prune_attachments.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/usage.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/src/pocketshell/usage_reset.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/__init__.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/test_agent_log.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/test_cli.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/test_daemon.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/test_env.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/test_github.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/test_hooks.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/test_jobs.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/test_logs.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/test_prune_attachments.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/test_qr_share.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/test_repos.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/test_usage.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/tests/test_usage_capture.py +0 -0
- {pocketshell-0.3.33 → pocketshell-0.4.0}/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
|
+
Version: 0.4.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
|
|
@@ -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.
|
|
11
|
+
version = "0.4.0"
|
|
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
|
|