-
Notifications
You must be signed in to change notification settings - Fork 71
Expand file tree
/
Copy pathpost_install.py
More file actions
309 lines (251 loc) · 8.54 KB
/
post_install.py
File metadata and controls
309 lines (251 loc) · 8.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
#!/usr/bin/env python3
"""Post-install configuration for Claude Code devcontainer.
Runs on container creation to set up:
- Onboarding bypass (when CLAUDE_CODE_OAUTH_TOKEN is set)
- Claude settings (bypassPermissions mode)
- Tmux configuration (200k history, mouse support)
- Directory ownership fixes for mounted volumes
"""
import contextlib
import json
import os
import subprocess
import sys
from pathlib import Path
def setup_onboarding_bypass():
"""Bypass the interactive onboarding wizard when CLAUDE_CODE_OAUTH_TOKEN is set.
Runs `claude -p` to seed ~/.claude.json with auth state. The subprocess
writes the config file during startup before the API call completes, so
a timeout is expected and acceptable. After the subprocess finishes (or
times out), we check whether ~/.claude.json was populated and only then
set hasCompletedOnboarding.
Workaround for https://github.com/anthropics/claude-code/issues/8938.
"""
token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
if not token:
print(
"[post_install] No CLAUDE_CODE_OAUTH_TOKEN set, skipping onboarding bypass",
file=sys.stderr,
)
return
# When `CLAUDE_CONFIG_DIR` is set, as is done in `devcontainer.json`, `claude` unexpectedly
# looks for `.claude.json` in *that* folder, instead of in `~`, contradicting the documentation.
# See https://github.com/anthropics/claude-code/issues/3833#issuecomment-3694918874
claude_json_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home()))
claude_json = claude_json_dir / ".claude.json"
print("[post_install] Running claude -p to populate auth state...", file=sys.stderr)
try:
result = subprocess.run(
["claude", "-p", "ok"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
print(
f"[post_install] claude -p exited {result.returncode}: "
f"{result.stderr.strip()}",
file=sys.stderr,
)
except subprocess.TimeoutExpired:
print(
"[post_install] claude -p timed out (expected on cold start)",
file=sys.stderr,
)
except (FileNotFoundError, OSError) as e:
print(
f"[post_install] Warning: could not run claude ({e}) — "
"onboarding bypass skipped",
file=sys.stderr,
)
return
if not claude_json.exists():
print(
f"[post_install] Warning: {claude_json} not created by claude -p — "
"onboarding bypass skipped",
file=sys.stderr,
)
return
config: dict = {}
try:
config = json.loads(claude_json.read_text())
except json.JSONDecodeError as e:
print(
f"[post_install] Warning: {claude_json} has invalid JSON ({e}), "
"starting fresh",
file=sys.stderr,
)
config["hasCompletedOnboarding"] = True
claude_json.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
print(
f"[post_install] Onboarding bypass configured: {claude_json}", file=sys.stderr
)
def setup_claude_settings():
"""Configure Claude Code with bypassPermissions enabled."""
claude_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude"))
claude_dir.mkdir(parents=True, exist_ok=True)
settings_file = claude_dir / "settings.json"
# Load existing settings or start fresh
settings = {}
if settings_file.exists():
with contextlib.suppress(json.JSONDecodeError):
settings = json.loads(settings_file.read_text())
# Set bypassPermissions mode
if "permissions" not in settings:
settings["permissions"] = {}
settings["permissions"]["defaultMode"] = "bypassPermissions"
settings_file.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
print(
f"[post_install] Claude settings configured: {settings_file}", file=sys.stderr
)
def setup_tmux_config():
"""Configure tmux with 200k history, mouse support, and vi keys."""
tmux_conf = Path.home() / ".tmux.conf"
if tmux_conf.exists():
print("[post_install] Tmux config exists, skipping", file=sys.stderr)
return
config = """\
# 200k line scrollback history
set-option -g history-limit 200000
# Enable mouse support
set -g mouse on
# Use vi keys in copy mode
setw -g mode-keys vi
# Start windows and panes at 1, not 0
set -g base-index 1
setw -g pane-base-index 1
# Renumber windows when one is closed
set -g renumber-windows on
# Faster escape time for vim
set -sg escape-time 10
# True color support
set -g default-terminal "tmux-256color"
set -ag terminal-overrides ",xterm-256color:RGB"
# Terminal features (ghostty, cursor shape in vim)
set -as terminal-features ",xterm-ghostty:RGB"
set -as terminal-features ",xterm*:RGB"
set -ga terminal-overrides ",xterm*:colors=256"
set -ga terminal-overrides '*:Ss=\\E[%p1%d q:Se=\\E[ q'
# Status bar
set -g status-style 'bg=#333333 fg=#ffffff'
set -g status-left '[#S] '
set -g status-right '%Y-%m-%d %H:%M'
"""
tmux_conf.write_text(config, encoding="utf-8")
print(f"[post_install] Tmux configured: {tmux_conf}", file=sys.stderr)
def fix_directory_ownership():
"""Fix ownership of mounted volumes that may have root ownership."""
uid = os.getuid()
gid = os.getgid()
dirs_to_fix = [
Path.home() / ".claude",
Path("/commandhistory"),
Path.home() / ".config" / "gh",
]
for dir_path in dirs_to_fix:
if dir_path.exists():
try:
# Use sudo to fix ownership if needed
stat_info = dir_path.stat()
if stat_info.st_uid != uid:
subprocess.run(
["sudo", "chown", "-R", f"{uid}:{gid}", str(dir_path)],
check=True,
capture_output=True,
)
print(
f"[post_install] Fixed ownership: {dir_path}", file=sys.stderr
)
except (PermissionError, subprocess.CalledProcessError) as e:
print(
f"[post_install] Warning: Could not fix ownership of {dir_path}: {e}",
file=sys.stderr,
)
def setup_global_gitignore():
"""Set up global gitignore and local git config.
Since ~/.gitconfig is mounted read-only from host, we create a local
config file that includes the host config and adds container-specific
settings like core.excludesfile and delta configuration.
GIT_CONFIG_GLOBAL env var (set in devcontainer.json) points git to this
local config as the "global" config.
"""
home = Path.home()
gitignore = home / ".gitignore_global"
local_gitconfig = home / ".gitconfig.local"
host_gitconfig = home / ".gitconfig"
# Create global gitignore with common patterns
patterns = """\
# Claude Code
.claude/
# macOS
.DS_Store
.AppleDouble
.LSOverride
._*
# Python
*.pyc
*.pyo
__pycache__/
*.egg-info/
.eggs/
*.egg
.venv/
venv/
.mypy_cache/
.ruff_cache/
# Node
node_modules/
.npm/
# Editors
*.swp
*.swo
*~
.idea/
.vscode/
*.sublime-*
# Misc
*.log
.env.local
.env.*.local
"""
gitignore.write_text(patterns, encoding="utf-8")
print(f"[post_install] Global gitignore created: {gitignore}", file=sys.stderr)
# Create local git config that includes host config and sets excludesfile + delta
# Delta config is included here so it works even if host doesn't have it configured
local_config = f"""\
# Container-local git config
# Includes host config (mounted read-only) and adds container settings
[include]
path = {host_gitconfig}
[core]
excludesfile = {gitignore}
pager = delta
[interactive]
diffFilter = delta --color-only
[delta]
navigate = true
light = false
line-numbers = true
side-by-side = false
[merge]
conflictstyle = diff3
[diff]
colorMoved = default
[gpg "ssh"]
program = /usr/bin/ssh-keygen
"""
local_gitconfig.write_text(local_config, encoding="utf-8")
print(
f"[post_install] Local git config created: {local_gitconfig}", file=sys.stderr
)
def main():
"""Run all post-install configuration."""
print("[post_install] Starting post-install configuration...", file=sys.stderr)
setup_onboarding_bypass()
setup_claude_settings()
setup_tmux_config()
fix_directory_ownership()
setup_global_gitignore()
print("[post_install] Configuration complete!", file=sys.stderr)
if __name__ == "__main__":
main()