codebay 0.12.1 → 0.14.0

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 (43) hide show
  1. package/.mochi/manifest.json +127 -111
  2. package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +1 -0
  3. package/.mochi/svelte-client/{_hydrate-AppShell_2rtnxn6pgp2rh-5kb6kh2d.js → _hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js} +2 -2
  4. package/.mochi/svelte-client/{_hydrate-AvatarsView_257s9yq1wwr4f-c51gt15m.js → _hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js} +1 -1
  5. package/.mochi/svelte-client/{_hydrate-InstanceView_2ma45l9bybfhl-d256bw54.js → _hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js} +1 -1
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +40 -0
  7. package/.mochi/svelte-client/{chunk-2eaagsfc.js → chunk-0mc09cyb.js} +1 -1
  8. package/.mochi/svelte-client/{chunk-a5e8vytv.js → chunk-5jwfs4p9.js} +2 -2
  9. package/.mochi/svelte-client/chunk-6qg4q98a.js +2 -0
  10. package/.mochi/svelte-client/{chunk-d8rdrqsh.js → chunk-ccfh8hxz.js} +1 -1
  11. package/.mochi/svelte-client/{chunk-d9bszcp8.js → chunk-phf7v5jj.js} +1 -1
  12. package/.mochi/svelte-client/{chunk-y1xjps59.js → chunk-spk1b2r4.js} +1 -1
  13. package/.mochi/svelte-client/chunk-tmeh70wg.js +2 -0
  14. package/.mochi/svelte-client/{chunk-6v5d7dex.js → chunk-vs1yjbqt.js} +1 -1
  15. package/.mochi/svelte-client/{chunk-1ttqgrz7.js → chunk-x0m3k0rn.js} +1 -1
  16. package/.mochi/svelte-client/{chunk-s70rry6z.js → chunk-y7f4jbdm.js} +1 -1
  17. package/.mochi/svelte-compile/{App-dn3kh0xn.server.js → App-94xsdn0w.server.js} +2 -2
  18. package/.mochi/svelte-compile/{Avatars-7k5055m8.server.js → Avatars-fnccw47h.server.js} +1 -1
  19. package/.mochi/svelte-compile/{Instance-hj76qnha.server.js → Instance-knnkcn2p.server.js} +1 -1
  20. package/.mochi/svelte-compile/{Settings-m2bfa4k4.server.js → Settings-a7b11v90.server.js} +274 -142
  21. package/.mochi/svelte-compile/{chunk-qc0f73f3.js → chunk-apdte4nh.js} +7 -8
  22. package/.mochi/svelte-compile/{chunk-zd49axkf.js → chunk-w0hca7va.js} +2 -1
  23. package/package.json +1 -1
  24. package/public/favicon.svg +9 -0
  25. package/src/avatars/bee.ts +7 -8
  26. package/src/components/SettingsView.svelte +148 -0
  27. package/src/container-injections/claude-code-credentials.ts +2 -3
  28. package/src/container-injections/claude-code-install.ts +12 -7
  29. package/src/container-injections/claude-code-skills.ts +125 -0
  30. package/src/container-injections/claude-code-update.ts +45 -10
  31. package/src/container-injections/claude-output-style.ts +83 -0
  32. package/src/container-injections/claude-statusline.ts +5 -16
  33. package/src/lib/container-files.server.ts +22 -0
  34. package/src/lib/host-claude.server.ts +31 -0
  35. package/src/lib/injections.server.ts +7 -1
  36. package/src/pages/Settings.svelte +12 -1
  37. package/src/routes.ts +19 -0
  38. package/src/shell.html +1 -0
  39. package/src/types.ts +16 -0
  40. package/.mochi/svelte-client/HydratableIsland-4j3bazeq.js +0 -1
  41. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-rw5z89pa.js +0 -39
  42. package/.mochi/svelte-client/chunk-pz6dq0v3.js +0 -2
  43. package/.mochi/svelte-client/chunk-wcfbjj3x.js +0 -2
@@ -70,16 +70,15 @@ var bear_default = art4;
70
70
  // src/avatars/bee.ts
71
71
  var art5 = {
72
72
  name: "bee",
73
- robot: true,
74
73
  pixels: [
75
- ".#....#.",
76
- "##.##.##",
77
- "#.####.#",
78
- ".######.",
79
- ".######.",
80
74
  "........",
81
- ".######.",
82
- "..####.."
75
+ "...++##.",
76
+ "..++###.",
77
+ "...+#...",
78
+ ".#####..",
79
+ "#.+#+###",
80
+ "##+#+##.",
81
+ ".#####.."
83
82
  ]
84
83
  };
85
84
  var bee_default = art5;
@@ -11,6 +11,7 @@ var CLAUDE_PERMISSION_MODES = [
11
11
  "plan"
12
12
  ];
13
13
  var CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
14
+ var CLAUDE_OUTPUT_STYLES = ["default", "none", "Concise"];
14
15
  function ideUrl(instance) {
15
16
  const base = `/p/${instance.id}/`;
16
17
  return instance.remote_workspace_folder ? `${base}?folder=${encodeURIComponent(instance.remote_workspace_folder)}` : base;
@@ -62,4 +63,4 @@ function applyTheme(theme) {
62
63
  document.documentElement.dataset.theme = theme;
63
64
  }
64
65
 
65
- export { CLAUDE_PERMISSION_MODES, CLAUDE_EFFORT_LEVELS, ideUrl, apiFetch, apiPost, apiDelete, getTheme, setTheme, applyTheme };
66
+ export { CLAUDE_PERMISSION_MODES, CLAUDE_EFFORT_LEVELS, CLAUDE_OUTPUT_STYLES, ideUrl, apiFetch, apiPost, apiDelete, getTheme, setTheme, applyTheme };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebay",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "description": "Web UI for spinning up isolated devcontainer instances from any local folder, each with a browser-based VS Code (code-server) — built for parallel Claude Code workflows.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
2
+ <rect x="0" y="0" width="24" height="24" fill="#14150f" />
3
+ <g fill="none" stroke="#d7d8d2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
4
+ <path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" />
5
+ <path d="M12 22V12" />
6
+ <polyline points="3.29 7 12 12 20.71 7" />
7
+ <path d="m7.5 4.27 9 5.15" />
8
+ </g>
9
+ </svg>
@@ -2,16 +2,15 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'bee',
5
- robot: true,
6
5
  pixels: [
7
- '.#....#.',
8
- '##.##.##',
9
- '#.####.#',
10
- '.######.',
11
- '.######.',
12
6
  '........',
13
- '.######.',
14
- '..####..'
7
+ '...++##.',
8
+ '..++###.',
9
+ '...+#...',
10
+ '.#####..',
11
+ '#.+#+###',
12
+ '##+#+##.',
13
+ '.#####..'
15
14
  ]
16
15
  };
17
16
 
@@ -10,6 +10,7 @@
10
10
  import ThemePicker from './ThemePicker.svelte';
11
11
  import Layers from '@lucide/svelte/icons/layers';
12
12
  import FolderMinus from '@lucide/svelte/icons/folder-minus';
13
+ import FolderCog from '@lucide/svelte/icons/folder-cog';
13
14
  import Trash2 from '@lucide/svelte/icons/trash-2';
14
15
  import Hammer from '@lucide/svelte/icons/hammer';
15
16
  import KeyRound from '@lucide/svelte/icons/key-round';
@@ -42,14 +43,17 @@
42
43
  import {
43
44
  CLAUDE_PERMISSION_MODES,
44
45
  CLAUDE_EFFORT_LEVELS,
46
+ CLAUDE_OUTPUT_STYLES,
45
47
  type ClaudePermissionMode,
46
48
  type ClaudeEffortLevel,
49
+ type ClaudeOutputStyle,
47
50
  type InstanceMode
48
51
  } from '../types.ts';
49
52
  import Terminal from '@lucide/svelte/icons/terminal';
50
53
  import LayoutTemplate from '@lucide/svelte/icons/layout-template';
51
54
  import ShieldCheck from '@lucide/svelte/icons/shield-check';
52
55
  import Gauge from '@lucide/svelte/icons/gauge';
56
+ import MessageSquare from '@lucide/svelte/icons/message-square';
53
57
  import { installPopupBackTrap } from '../lib/popup-nav.ts';
54
58
 
55
59
  /** Every settings form action fails with the same `{ error }` shape. */
@@ -60,11 +64,13 @@
60
64
  defaultMode,
61
65
  claudePermissionMode,
62
66
  claudeEffortLevel,
67
+ claudeOutputStyle,
63
68
  defaultImage,
64
69
  builtinImage,
65
70
  disableBuildCache,
66
71
  copyIgnorePatterns,
67
72
  builtinCopyIgnore,
73
+ claudeConfigDir,
68
74
  gitIdentityEnabled,
69
75
  gitIdentityName,
70
76
  gitIdentityEmail,
@@ -100,11 +106,13 @@
100
106
  defaultMode: InstanceMode;
101
107
  claudePermissionMode: ClaudePermissionMode;
102
108
  claudeEffortLevel: ClaudeEffortLevel;
109
+ claudeOutputStyle: ClaudeOutputStyle;
103
110
  defaultImage: string;
104
111
  builtinImage: string;
105
112
  disableBuildCache: boolean;
106
113
  copyIgnorePatterns: string;
107
114
  builtinCopyIgnore: string;
115
+ claudeConfigDir: string;
108
116
  gitIdentityEnabled: boolean;
109
117
  gitIdentityName: string;
110
118
  gitIdentityEmail: string;
@@ -292,6 +300,33 @@
292
300
  effortFormEl?.requestSubmit();
293
301
  }
294
302
 
303
+ // svelte-ignore state_referenced_locally
304
+ let outputStyleChoice = $state<ClaudeOutputStyle>(claudeOutputStyle);
305
+ let savingOutputStyle = $state(false);
306
+ let outputStyleError = $state<string | null>(null);
307
+ let outputStyleFormEl: HTMLFormElement | undefined;
308
+
309
+ const outputStyleOpts = saveOpts<{ style: ClaudeOutputStyle }>({
310
+ setSaving: (v) => (savingOutputStyle = v),
311
+ setError: (v) => (outputStyleError = v),
312
+ setMsg: () => {},
313
+ onSuccess: (data) => {
314
+ if (data?.style) outputStyleChoice = data.style;
315
+ }
316
+ });
317
+
318
+ const OUTPUT_STYLE_LABELS: Record<ClaudeOutputStyle, string> = {
319
+ default: 'Default',
320
+ none: 'None',
321
+ Concise: 'Concise'
322
+ };
323
+
324
+ function chooseOutputStyle(next: ClaudeOutputStyle) {
325
+ if (next === outputStyleChoice) return;
326
+ flushSync(() => (outputStyleChoice = next));
327
+ outputStyleFormEl?.requestSubmit();
328
+ }
329
+
295
330
  // svelte-ignore state_referenced_locally
296
331
  let image = $state(defaultImage);
297
332
  let savingImage = $state(false);
@@ -340,6 +375,29 @@
340
375
  copyIgnoreFormEl?.requestSubmit();
341
376
  }
342
377
 
378
+ // Blank means "use ~/.claude" — the host dir the Claude config injections read from.
379
+ // svelte-ignore state_referenced_locally
380
+ let claudeDir = $state(claudeConfigDir);
381
+ let savingClaudeDir = $state(false);
382
+ let claudeDirError = $state<string | null>(null);
383
+ let claudeDirSaved = $state(false);
384
+ let claudeDirFormEl: HTMLFormElement | undefined;
385
+
386
+ const claudeDirOpts = saveOpts<{ dir: string }>({
387
+ setSaving: (v) => (savingClaudeDir = v),
388
+ setError: (v) => (claudeDirError = v),
389
+ setMsg: (v) => (claudeDirSaved = !!v),
390
+ onSuccess: (data) => {
391
+ claudeDir = data?.dir ?? claudeDir;
392
+ claudeDirSaved = true;
393
+ }
394
+ });
395
+
396
+ function resetClaudeDir() {
397
+ flushSync(() => (claudeDir = ''));
398
+ claudeDirFormEl?.requestSubmit();
399
+ }
400
+
343
401
  // svelte-ignore state_referenced_locally
344
402
  let gitIdentity = $state(gitIdentityEnabled);
345
403
  let savingGitToggle = $state(false);
@@ -973,6 +1031,48 @@
973
1031
  {/if}
974
1032
  </section>
975
1033
 
1034
+ <section class="card">
1035
+ <form
1036
+ class="row"
1037
+ method="POST"
1038
+ action="?/claudeOutputStyle"
1039
+ bind:this={outputStyleFormEl}
1040
+ {@attach enhance(outputStyleOpts)}
1041
+ >
1042
+ <div class="label">
1043
+ <MessageSquare size={20} />
1044
+ <div class="text">
1045
+ <div class="name">Claude output style</div>
1046
+ <div class="desc">
1047
+ The output style Claude Code starts new sessions in, written to
1048
+ <code>~/.claude/settings.json</code>. <strong>Default</strong> inherits your host
1049
+ <code>~/.claude/settings.json</code> (including any custom styles);
1050
+ <strong>None</strong>
1051
+ forces it off. Applies on create and rebuild, not to already-running instances.
1052
+ </div>
1053
+ </div>
1054
+ </div>
1055
+ <input type="hidden" name="style" value={outputStyleChoice} />
1056
+ <div class="mode-toggle" role="group" aria-label="Claude output style">
1057
+ {#each CLAUDE_OUTPUT_STYLES as option (option)}
1058
+ <button
1059
+ type="button"
1060
+ class="mode-btn"
1061
+ class:active={outputStyleChoice === option}
1062
+ aria-pressed={outputStyleChoice === option}
1063
+ disabled={savingOutputStyle}
1064
+ onclick={() => chooseOutputStyle(option)}
1065
+ >
1066
+ {OUTPUT_STYLE_LABELS[option]}
1067
+ </button>
1068
+ {/each}
1069
+ </div>
1070
+ </form>
1071
+ {#if outputStyleError}
1072
+ <div class="sub"><div class="msg error">{outputStyleError}</div></div>
1073
+ {/if}
1074
+ </section>
1075
+
976
1076
  <section class="card">
977
1077
  <form
978
1078
  class="row image-row"
@@ -1082,6 +1182,54 @@
1082
1182
  </form>
1083
1183
  </section>
1084
1184
 
1185
+ <section class="card">
1186
+ <form
1187
+ class="row image-row"
1188
+ method="POST"
1189
+ action="?/claudeConfigDir"
1190
+ bind:this={claudeDirFormEl}
1191
+ {@attach enhance(claudeDirOpts)}
1192
+ >
1193
+ <div class="label">
1194
+ <FolderCog size={20} />
1195
+ <div class="text">
1196
+ <div class="name">Claude config directory</div>
1197
+ <div class="desc">
1198
+ Host directory the Claude Code injections read from — credentials, statusLine, output
1199
+ styles, and global skills / <code>CLAUDE.md</code>. Leave blank to use
1200
+ <code>~/.claude</code>. Takes effect for instances created from now on.
1201
+ </div>
1202
+ </div>
1203
+ </div>
1204
+ <div class="image-controls">
1205
+ <input
1206
+ type="text"
1207
+ name="dir"
1208
+ class="image-input"
1209
+ bind:value={claudeDir}
1210
+ spellcheck="false"
1211
+ autocapitalize="off"
1212
+ autocorrect="off"
1213
+ placeholder="~/.claude"
1214
+ />
1215
+ <Button type="submit" disabled={savingClaudeDir}>Save</Button>
1216
+ <Button
1217
+ type="button"
1218
+ icon={RotateCcw}
1219
+ disabled={savingClaudeDir}
1220
+ onclick={resetClaudeDir}
1221
+ title="Reset to default (~/.claude)"
1222
+ aria-label="Reset to default Claude config directory"
1223
+ />
1224
+ </div>
1225
+ {#if claudeDirError}
1226
+ <div class="msg error">{claudeDirError}</div>
1227
+ {:else if claudeDirSaved}
1228
+ <div class="msg ok">Saved.</div>
1229
+ {/if}
1230
+ </form>
1231
+ </section>
1232
+
1085
1233
  <section class="card">
1086
1234
  <form
1087
1235
  class="row"
@@ -1,7 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
4
- import { join } from 'node:path';
3
+ import { hostClaudeFile } from '../lib/host-claude.server.ts';
5
4
  import { CLAUDE_CODE_TOKEN, CLAUDE_KEYCHAIN_SERVICE } from '../lib/config.server.ts';
6
5
  import { getOption } from '../lib/db.server.ts';
7
6
  import { checkPresence } from '../lib/exec.server.ts';
@@ -84,7 +83,7 @@ async function locateClaudeCredentials(): Promise<{ creds: string; source: strin
84
83
  }
85
84
  }
86
85
 
87
- const file = join(homedir(), '.claude', '.credentials.json');
86
+ const file = hostClaudeFile('.credentials.json');
88
87
  if (existsSync(file)) {
89
88
  const raw = (await readFile(file, 'utf8')).trim();
90
89
  if (isValid(raw)) return { creds: raw, source: '~/.claude/.credentials.json' };
@@ -8,20 +8,25 @@ import type { Injection } from '../lib/injections.server.ts';
8
8
  * Node, else the standalone installer, which needs no Node at all.
9
9
  *
10
10
  * `$1` is the remote user (the injection passes it; the build-time feature falls back to the
11
- * devcontainer CLI's `$_REMOTE_USER`) — the standalone installer puts the binary under that
12
- * user's `$HOME`, so running it as root would strand it in `/root`.
11
+ * devcontainer CLI's `$_REMOTE_USER`). Claude Code 2.x's native binary — installed by npm's
12
+ * postinstall as much as by the standalone installer — lands under that user's `$HOME`, so both
13
+ * branches run as that user via `run_as`; running as root would strand the binary in `/root`.
14
+ * Success is judged by `claude --version` (as the user), not `command -v claude`, so a stranded
15
+ * binary the wrapper still resolves falls through to the standalone installer instead of passing.
13
16
  */
14
17
  export const INSTALL_SCRIPT =
15
18
  'if command -v claude >/dev/null 2>&1; then exit 0; fi\n' +
16
- 'if command -v npm >/dev/null 2>&1 && npm install -g @anthropic-ai/claude-code@latest >/dev/null 2>&1; then\n' +
17
- ' p="$(npm prefix -g 2>/dev/null)"\n' +
19
+ 'u="${1:-${_REMOTE_USER:-root}}"\n' +
20
+ // Fall back to a home the remote user owns, never root's, so `su -m` can't strand the binary in /root.
21
+ 'h="$(getent passwd "$u" 2>/dev/null | cut -d: -f6)"; [ -n "$h" ] || { [ "$u" = root ] && h=/root || h="/home/$u"; }\n' +
22
+ 'run_as() { if [ "$(id -un)" = "$u" ]; then HOME="$h" sh -c "$1"; else HOME="$h" su -m "$u" -c "$1"; fi; }\n' +
23
+ "if command -v npm >/dev/null 2>&1 && run_as 'npm install -g @anthropic-ai/claude-code@latest >/dev/null 2>&1'; then\n" +
24
+ ' p="$(run_as \'npm prefix -g 2>/dev/null\')"\n' +
18
25
  // npm's global bin is off PATH whenever the image sets its own NPM_CONFIG_PREFIX, so a bare
19
26
  // `command -v` would miss an install that just succeeded — and so would the terminal launcher.
20
27
  ' [ -n "$p" ] && [ -x "$p/bin/claude" ] && ln -sf "$p/bin/claude" /usr/local/bin/claude 2>/dev/null\n' +
21
- ' if command -v claude >/dev/null 2>&1 || [ -x "$p/bin/claude" ]; then exit 0; fi\n' +
28
+ " if run_as 'claude --version >/dev/null 2>&1'; then exit 0; fi\n" +
22
29
  'fi\n' +
23
- 'u="${1:-${_REMOTE_USER:-root}}"\n' +
24
- 'h="$(getent passwd "$u" 2>/dev/null | cut -d: -f6)"; [ -n "$h" ] || h="$HOME"\n' +
25
30
  'f="$(mktemp)"\n' +
26
31
  'if command -v curl >/dev/null 2>&1; then curl -fsSL https://claude.ai/install.sh -o "$f"\n' +
27
32
  'elif command -v wget >/dev/null 2>&1; then wget -qO "$f" https://claude.ai/install.sh\n' +
@@ -0,0 +1,125 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
+ import { posix } from 'node:path';
4
+ import { checkPresence } from '../lib/exec.server.ts';
5
+ import { writeContainerFileBytes } from '../lib/container-files.server.ts';
6
+ import { claudeConfigFile } from '../lib/claude-settings.server.ts';
7
+ import { hostClaudeFile } from '../lib/host-claude.server.ts';
8
+ import type { Injection } from '../lib/injections.server.ts';
9
+
10
+ /**
11
+ * The env-var stdin carrier caps a single write near 128 KB (Linux `MAX_ARG_STRLEN`); base64
12
+ * inflates ~4/3, so a raw file over this is skipped rather than failing the whole injection.
13
+ */
14
+ const MAX_FILE_BYTES = 90_000;
15
+
16
+ interface HostFile {
17
+ /** Path relative to the host Claude dir, e.g. `CLAUDE.md` or `skills/foo/SKILL.md`. */
18
+ rel: string;
19
+ bytes: Buffer;
20
+ /** True when any execute bit is set on the host, so container scripts stay runnable. */
21
+ exec: boolean;
22
+ oversized: boolean;
23
+ }
24
+
25
+ /** Recursively lists every file under a host dir, as `skills/<subpath>` entries. */
26
+ async function walkSkills(dir: string, rel: string, out: HostFile[]): Promise<void> {
27
+ let entries;
28
+ try {
29
+ entries = await readdir(dir, { withFileTypes: true });
30
+ } catch {
31
+ return;
32
+ }
33
+ for (const entry of entries) {
34
+ const abs = posix.join(dir, entry.name);
35
+ const childRel = posix.join(rel, entry.name);
36
+ if (entry.isDirectory()) {
37
+ await walkSkills(abs, childRel, out);
38
+ } else if (entry.isFile()) {
39
+ const info = await stat(abs);
40
+ out.push({
41
+ rel: childRel,
42
+ bytes: info.size > MAX_FILE_BYTES ? Buffer.alloc(0) : await readFile(abs),
43
+ exec: (info.mode & 0o111) !== 0,
44
+ oversized: info.size > MAX_FILE_BYTES
45
+ });
46
+ }
47
+ }
48
+ }
49
+
50
+ /** Cheap presence probe for the setup-UI chip — never reads file contents. */
51
+ export async function hasHostSkillFiles(): Promise<boolean> {
52
+ if (existsSync(hostClaudeFile('CLAUDE.md'))) return true;
53
+ try {
54
+ return (await readdir(hostClaudeFile('skills'))).length > 0;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /** The host global CLAUDE.md plus every file under the host skills dir. */
61
+ export async function collectHostSkillFiles(): Promise<HostFile[]> {
62
+ const files: HostFile[] = [];
63
+ const claudeMd = hostClaudeFile('CLAUDE.md');
64
+ if (existsSync(claudeMd)) {
65
+ const info = await stat(claudeMd);
66
+ if (info.isFile()) {
67
+ files.push({
68
+ rel: 'CLAUDE.md',
69
+ bytes: info.size > MAX_FILE_BYTES ? Buffer.alloc(0) : await readFile(claudeMd),
70
+ exec: false,
71
+ oversized: info.size > MAX_FILE_BYTES
72
+ });
73
+ }
74
+ }
75
+ await walkSkills(hostClaudeFile('skills'), 'skills', files);
76
+ return files;
77
+ }
78
+
79
+ export const claudeCodeSkills: Injection = {
80
+ id: 'claude-code-skills',
81
+ label: 'skills & CLAUDE.md',
82
+
83
+ auth: {
84
+ hint: 'add skills or a CLAUDE.md to your host ~/.claude',
85
+ async status() {
86
+ const available = await hasHostSkillFiles();
87
+ return { available, source: available ? '~/.claude' : null };
88
+ }
89
+ },
90
+
91
+ async apply(target, log) {
92
+ const files = await collectHostSkillFiles();
93
+ if (!files.length) {
94
+ log('⚠ No global skills or CLAUDE.md found on host; skipped\n');
95
+ return;
96
+ }
97
+ log(`Injecting ${files.length} global skill/CLAUDE.md file(s)…\n`);
98
+ let failed = 0;
99
+ for (const file of files) {
100
+ if (file.oversized) {
101
+ log(`⚠ Skipped ${file.rel} (larger than ${MAX_FILE_BYTES} bytes)\n`);
102
+ continue;
103
+ }
104
+ const dest = claudeConfigFile(file.rel, file.exec ? '755' : '644');
105
+ const wrote = await writeContainerFileBytes(target, dest, file.bytes);
106
+ if (!wrote.ok) {
107
+ failed++;
108
+ log(`⚠ Failed to write ${file.rel}: ${wrote.error}\n`);
109
+ }
110
+ }
111
+ log(
112
+ failed
113
+ ? `⚠ ${failed} skill file(s) failed to inject\n`
114
+ : '✓ Global skills & CLAUDE.md injected\n'
115
+ );
116
+ },
117
+
118
+ async check(target) {
119
+ return checkPresence(
120
+ target,
121
+ 'h=$(eval echo ~$(id -un)); d="${CLAUDE_CONFIG_DIR:-$h/.claude}"; ' +
122
+ 'if [ -s "$d/CLAUDE.md" ] || [ -n "$(ls -A "$d/skills" 2>/dev/null)" ]; then echo 1; else echo 0; fi'
123
+ );
124
+ }
125
+ };
@@ -8,29 +8,63 @@ import type { Injection } from '../lib/injections.server.ts';
8
8
  * was current when the layer was first baked. This re-checks at boot and only reinstalls when
9
9
  * behind — no-op if Claude Code isn't installed (project-owned images own their tooling) or
10
10
  * npm/registry is unreachable, and it echoes `updated <old> -> <new>` so `apply()` can log it.
11
+ *
12
+ * `$1` is the container's remote user. Claude Code 2.x ships a native binary that npm's
13
+ * postinstall drops into the *invoking* user's HOME, so the reinstall (and the `claude --version`
14
+ * probes) run as that user via `run_as` — a root reinstall would strand the binary in `/root`,
15
+ * exactly as the standalone installer in `claude-code-install.ts` would. Success is judged by a
16
+ * post-install `claude --version` (as the remote user), not npm's exit code, so a stranded or
17
+ * half-downloaded binary surfaces as a failure instead of a false `updated`.
11
18
  */
19
+ const RUN_AS_PRELUDE =
20
+ 'u="${1:-root}"; ' +
21
+ // Fall back to a home the remote user actually owns, never root's — otherwise `su -m` below
22
+ // would strand the native binary in /root, the exact failure this exists to prevent.
23
+ 'h="$(getent passwd "$u" 2>/dev/null | cut -d: -f6)"; [ -n "$h" ] || { [ "$u" = root ] && h=/root || h="/home/$u"; }; ' +
24
+ 'run_as() { if [ "$(id -un)" = "$u" ]; then HOME="$h" sh -c "$1"; else HOME="$h" su -m "$u" -c "$1"; fi; }; ' +
25
+ "ver() { run_as 'claude --version 2>/dev/null' | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -n1; }; ";
26
+
27
+ const NPM_INSTALL_CMD = 'npm install -g @anthropic-ai/claude-code@latest >/dev/null 2>&1';
28
+
29
+ /**
30
+ * Prefer the remote user — correct HOME and ownership on the common image whose global prefix is
31
+ * chowned to that user. Fall back to a root install with HOME pointed at the user's home when the
32
+ * user can't write a root-owned global prefix (`su` would EACCES there). Success is decided by the
33
+ * post-install `ver()`, so neither branch's exit status matters.
34
+ */
35
+ const REINSTALL =
36
+ `if run_as '${NPM_INSTALL_CMD}'; then :; ` +
37
+ `elif [ "$(id -un)" != "$u" ]; then HOME="$h" ${NPM_INSTALL_CMD} || true; fi; `;
38
+
12
39
  export const UPDATE_SCRIPT =
13
40
  'command -v claude >/dev/null 2>&1 || exit 0; ' +
14
41
  'command -v npm >/dev/null 2>&1 || exit 0; ' +
15
- "installed=$(claude --version 2>/dev/null | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -n1); " +
42
+ RUN_AS_PRELUDE +
43
+ 'installed="$(ver)"; ' +
16
44
  'latest=$(npm view @anthropic-ai/claude-code version 2>/dev/null); ' +
17
45
  'if [ -z "$latest" ]; then exit 0; fi; ' +
18
46
  'if [ "$installed" = "$latest" ]; then echo "current $latest"; exit 0; fi; ' +
19
- 'npm install -g @anthropic-ai/claude-code@latest >/dev/null 2>&1 || exit 1; ' +
20
- 'echo "updated $installed -> $latest"';
47
+ REINSTALL +
48
+ 'now="$(ver)"; ' +
49
+ 'if [ "$now" = "$latest" ]; then echo "updated ${installed:-none} -> $now"; exit 0; fi; ' +
50
+ 'exit 1';
21
51
 
22
52
  /**
23
53
  * Same check with the latest version supplied by the host as `$0` (exec args, never interpolated
24
- * into the script), skipping the in-container `npm view` registry round-trip.
54
+ * into the script), skipping the in-container `npm view` registry round-trip. `$1` is the remote
55
+ * user — see `UPDATE_SCRIPT` above for why the reinstall must run as that user.
25
56
  */
26
57
  export const PINNED_UPDATE_SCRIPT =
27
58
  'latest="$0"; ' +
28
59
  'command -v claude >/dev/null 2>&1 || exit 0; ' +
29
60
  'command -v npm >/dev/null 2>&1 || exit 0; ' +
30
- "installed=$(claude --version 2>/dev/null | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -n1); " +
61
+ RUN_AS_PRELUDE +
62
+ 'installed="$(ver)"; ' +
31
63
  'if [ "$installed" = "$latest" ]; then echo "current $latest"; exit 0; fi; ' +
32
- 'npm install -g @anthropic-ai/claude-code@latest >/dev/null 2>&1 || exit 1; ' +
33
- 'echo "updated $installed -> $latest"';
64
+ REINSTALL +
65
+ 'now="$(ver)"; ' +
66
+ 'if [ "$now" = "$latest" ]; then echo "updated ${installed:-none} -> $now"; exit 0; fi; ' +
67
+ 'exit 1';
34
68
 
35
69
  /** Anything looser could smuggle shell metacharacters out of a corrupted DB row into a container. */
36
70
  export const VERSION_RE = /^\d+\.\d+\.\d+$/;
@@ -84,12 +118,13 @@ export const claudeCodeUpdate: Injection = {
84
118
  async apply(target, log) {
85
119
  log('Checking Claude Code is up to date…\n');
86
120
  const latest = await resolveLatestVersion();
87
- // Omitting remoteUser runs as root, which the global npm reinstall needs.
121
+ // Runs as root (remoteUser omitted) so the script can `su -m` into the remote user for the
122
+ // reinstall, keeping Claude Code 2.x's native binary in that user's HOME (see UPDATE_SCRIPT).
88
123
  const res = await execInContainer(
89
124
  { containerId: target.containerId },
90
125
  latest
91
- ? { script: PINNED_UPDATE_SCRIPT, args: [latest], capture: true }
92
- : { script: UPDATE_SCRIPT, capture: true }
126
+ ? { script: PINNED_UPDATE_SCRIPT, args: [latest, target.remoteUser ?? ''], capture: true }
127
+ : { script: UPDATE_SCRIPT, args: ['claude-update', target.remoteUser ?? ''], capture: true }
93
128
  );
94
129
  if (!res.ok) {
95
130
  log(`⚠ Claude Code update check failed: ${res.error} — keeping the installed version\n`);
@@ -0,0 +1,83 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { getOption } from '../lib/db.server.ts';
4
+ import { writeContainerFile } from '../lib/container-files.server.ts';
5
+ import {
6
+ claudeConfigFile,
7
+ mergeClaudeSettings,
8
+ readClaudeSettings
9
+ } from '../lib/claude-settings.server.ts';
10
+ import { hostClaudeFile, readHostClaudeSettings } from '../lib/host-claude.server.ts';
11
+ import { normalizeOutputStyle } from '../types.ts';
12
+ import type { ClaudeOutputStyle } from '../types.ts';
13
+ import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
14
+
15
+ /** Also read by the settings-page `serverProps` to seed the UI. */
16
+ export function getClaudeOutputStyle(): ClaudeOutputStyle {
17
+ return normalizeOutputStyle(getOption('claude_output_style'));
18
+ }
19
+
20
+ /** The `outputStyle` actually applied: the setting itself, the host's value when inheriting, or null. */
21
+ export async function resolveEffectiveOutputStyle(): Promise<string | null> {
22
+ const setting = getClaudeOutputStyle();
23
+ if (setting === 'none') return null;
24
+ if (setting !== 'default') return setting;
25
+ const style = (await readHostClaudeSettings())?.outputStyle;
26
+ return typeof style === 'string' && style.trim() ? style.trim() : null;
27
+ }
28
+
29
+ /** The host's custom output-style definitions, so a non-built-in name resolves inside the container. */
30
+ export async function readHostOutputStyleFiles(): Promise<{ name: string; content: string }[]> {
31
+ const dir = hostClaudeFile('output-styles');
32
+ let names: string[];
33
+ try {
34
+ names = (await readdir(dir)).filter((n) => n.endsWith('.md'));
35
+ } catch {
36
+ return [];
37
+ }
38
+ const files = await Promise.all(
39
+ names.map(async (name) => {
40
+ try {
41
+ return { name, content: await readFile(join(dir, name), 'utf8') };
42
+ } catch {
43
+ return null;
44
+ }
45
+ })
46
+ );
47
+ return files.filter((f): f is { name: string; content: string } => f !== null);
48
+ }
49
+
50
+ /** Writes Claude Code's `outputStyle` default into the container, mirroring host custom-style files. */
51
+ export const claudeOutputStyle: Injection = {
52
+ id: 'claude-output-style',
53
+ label: 'output style',
54
+
55
+ async apply(target: ContainerTarget, log) {
56
+ const style = await resolveEffectiveOutputStyle();
57
+ if (!style) {
58
+ log('No Claude output style to apply; skipped\n');
59
+ return;
60
+ }
61
+ for (const file of await readHostOutputStyleFiles()) {
62
+ await writeContainerFile(
63
+ target,
64
+ claudeConfigFile(`output-styles/${file.name}`, '644'),
65
+ file.content
66
+ );
67
+ }
68
+ log(`Setting Claude output style (${style})…\n`);
69
+ const result = await mergeClaudeSettings(target, { outputStyle: style });
70
+ log(
71
+ result.ok
72
+ ? '✓ Claude output style configured in container\n'
73
+ : `⚠ Claude output style injection failed: ${result.error}\n`
74
+ );
75
+ },
76
+
77
+ async check(target) {
78
+ const expected = await resolveEffectiveOutputStyle();
79
+ const settings = await readClaudeSettings(target);
80
+ const actual = settings?.outputStyle;
81
+ return expected === null ? actual == null : actual === expected;
82
+ }
83
+ };